diff --git a/browser-host/workflow-broker.test.mjs b/browser-host/workflow-broker.test.mjs index 01c6b82282..91fd5bef39 100644 --- a/browser-host/workflow-broker.test.mjs +++ b/browser-host/workflow-broker.test.mjs @@ -411,6 +411,14 @@ it("existing backend signs only canonical workflow sign/publish with exact own e ["a", `30620:${h.viewer}:${id}`], ], }, + // The human signs an agent-targeted deletion; the relay checks ownership. + { + ...template(5), + tags: [ + ["h", runId], + ["a", `30620:${"a".repeat(64)}:${id}`], + ], + }, ]) { const event = await t.writer.sign(input, signal()); expect(verifyEvent(event)).toBe(true); @@ -421,7 +429,7 @@ it("existing backend signs only canonical workflow sign/publish with exact own e expect(await t.writer.publish(event, signal())).toBe("workflow-result"); expect(h.publications.at(-1)).toEqual(JSON.parse(JSON.stringify(event))); } - expect(h.publications).toHaveLength(4); + expect(h.publications).toHaveLength(5); expect(h.calls).toHaveLength(0); } finally { live?.dispose(); @@ -462,12 +470,12 @@ const invalidCommands = [ }), ], [ - "other owner", + "malformed owner", () => ({ ...template(5), tags: [ ["h", runId], - ["a", `30620:${"a".repeat(64)}:${id}`], + ["a", `30620:invalid:${id}`], ], }), ], diff --git a/docs/buzz-help.md b/docs/buzz-help.md index b2a8412dd3..b66121dc1b 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -549,3 +549,14 @@ With the pinned-viewer development server, Settings → Plugins → Builderlab m ### Reconnecting an old agent in a DM On desktop, **Connect [agent name]** may appear inside a DM with an old agent you own that is available to import from this computer. It preselects compatible saved harness, provider and model choices, and preserves saved effort settings. Choose a harness if the old type is unavailable; settings that were not saved use the selected type’s defaults. Review the inline controls, complete installation or sign-in if prompted, stop the agent in old Buzz, then choose **Connect**. Your agent keeps its identity and conversation. This does not resend earlier messages. The same private card can appear in a channel or thread after you mention an eligible agent you own. Only you see it, once per agent in the loaded conversation. After connecting, **Ask the agent to respond** explicitly queues a follow-up addressed only to that agent in the original message’s thread; it does not automatically replay your request. If no prompt appears, use **Agents → New agent → Import an agent**. + +## Deleting an agent’s workflow + +In **Workflows → Owner → All channel workflows**, open the workflow’s actions +menu and choose **Delete workflow**. You can delete workflows you authored or +workflows authored by an agent whose ownership Buzz has verified belongs to you. +This also works when your channel role is Guest. +Confirm the deletion; pending or uncertain results remain in the workflow notices. +If permission changes during confirmation, Buzz explains why deletion could not +start and keeps any editor draft. +Owning the agent does not grant permission to edit or run its workflow. diff --git a/docs/workflows.md b/docs/workflows.md index 0e4a2ca899..07edc7eb5c 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -31,8 +31,16 @@ See the [capability contract](../src/features/workflows/types.ts). the active naming policy. Fallbacks lengthen their suffix to stay distinct across visible owners. Visible owners hydrate together in background profile batches of up to 500 identities. Narrow rows reflow their artwork, text, and - independent controls as text grows. Ownership and management - permissions remain tied to the signing identity, including agent-owned workflows. + independent controls as text grows. Editing, enabling and running remain tied + to the workflow author. A verified owner of an agent can also delete that + agent's workflows, signing as themselves; + select All channel workflows to find them. This ownership-authorized deletion + also works with a Guest channel role. Ownership claims in display metadata + do not grant permission, and the relay authorizes every deletion. Losing the + verified ownership proof disables deletion in the app; it does not clear the + relay's separately recorded agent owner. + If permission changes during confirmation, an error explains why deletion + could not start; the editor retains its draft. Clearing the owner filter or Clear all shows all channel workflows. Enabled and Disabled match saved configuration (omitted means enabled), not confirmed runtime activity; unreadable configurations are excluded from both. diff --git a/src-tauri/src/relay.rs b/src-tauri/src/relay.rs index b5f68ed550..20253b4f7f 100644 --- a/src-tauri/src/relay.rs +++ b/src-tauri/src/relay.rs @@ -313,9 +313,12 @@ pub(crate) async fn relay_sign( { return Err("Relay event is too large".into()); } - if coordinate_delete - .is_some_and(|coordinate| coordinate.split(':').nth(1) != signed["pubkey"].as_str()) - { + // Canonical workflow deletes may target an agent owned by the signer. + // The relay authorizes ownership; other coordinate deletes remain author-only. + if coordinate_delete.is_some_and(|coordinate| { + !coordinate.starts_with("30620:") + && coordinate.split(':').nth(1) != signed["pubkey"].as_str() + }) { return Err("Only the author can delete this event".into()); } Ok(signed) diff --git a/src-tauri/src/relay/tests.rs b/src-tauri/src/relay/tests.rs index c03a6575d3..67a4642a01 100644 --- a/src-tauri/src/relay/tests.rs +++ b/src-tauri/src/relay/tests.rs @@ -837,16 +837,22 @@ fn isolated_agent_ipc_probe() { } })).is_err()); let id = "11111111-1111-4111-8111-111111111111"; - assert!(invoke( + let agent_deletion = invoke( "relay_sign", serde_json::json!({ "community": "https://relay.test", "event": { "kind": 5, "created_at": 123, "tags": [["h", id], ["a", format!("30620:{}:{id}", "a".repeat(64))]], "content": "" } - }) + }), ) - .is_err()); + .unwrap(); + verify(&agent_deletion); + assert_eq!(agent_deletion["pubkey"], public); + assert_eq!( + agent_deletion["tags"][1][1], + format!("30620:{}:{id}", "a".repeat(64)) + ); let deletion = invoke("relay_sign", serde_json::json!({ "community": "https://relay.test", "event": { "kind": 5, "created_at": 123, diff --git a/src/bundled/workflows/WorkflowChannel.tsx b/src/bundled/workflows/WorkflowChannel.tsx index 97d7e30859..7700a0ae57 100644 --- a/src/bundled/workflows/WorkflowChannel.tsx +++ b/src/bundled/workflows/WorkflowChannel.tsx @@ -1,5 +1,6 @@ import { useEffect, + useId, useCallback, useRef, useState, @@ -10,7 +11,11 @@ import type { WorkflowDefinition, } from "../../features/workflows/types"; import { PlayIcon, TrashIcon } from "../../shared/design-system/icons"; -import { MenuIcon, MenuItem } from "../../shared/design-system/ui/Menu"; +import { + MenuIcon, + MenuItem, + MenuNote, +} from "../../shared/design-system/ui/Menu"; import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { ConfirmAction } from "./ConfirmAction"; @@ -21,6 +26,7 @@ import { saveAwaitsReadback } from "./WorkflowLanding"; import { exactSaveReadback } from "./editor-model"; import { DEFAULT_FORM_STATE, formStateToYaml } from "./workflowFormTypes"; import { useWorkflowView } from "./useWorkflowView"; +import { useWorkflowCanDelete } from "./useWorkflowCanDelete"; import { type ChannelLabel, channelIcon, @@ -82,6 +88,7 @@ export function WorkflowChannel({ capability.operations.snapshot, ); const submission = useRef(null); + const deleteReasonId = useId(); const editorGeneration = useRef(0); const [draft, setDraft] = useState(() => draftFor(initialSelection, 0), @@ -106,6 +113,7 @@ export function WorkflowChannel({ !!draft?.operationId && (!operation || operation.outcome === "pending"); const [localDraftAtRisk, setLocalDraftAtRisk] = useState(false); const readonly = !!draft?.original && draft.original.owner !== viewer; + const canDelete = useWorkflowCanDelete(capability, draft?.original); const dirty = !!draft && (draft.yaml !== draft.initial || localDraftAtRisk); const atRisk = dirty || !!draft?.operationId; // A run request never changes configuration, so its lost receipt cannot @@ -236,12 +244,22 @@ export function WorkflowChannel({ if ( !draft?.original || submission.current || - readonly || unresolvedWrite || - !capability.availability.delete || draft.operationId ) return; + if ( + !capability.permissions.canDelete(draft.original) || + !capability.availability.delete + ) { + setConfirmDelete(false); + setError( + capability.availability.delete + ? "You no longer have permission to delete this workflow. Refresh and try again." + : "Workflow deletion is unavailable on this connection.", + ); + return; + } // The page owns submission and recovery after this editor is discarded. const definition = draft.original; setDraft(null); @@ -371,21 +389,31 @@ export function WorkflowChannel({ ) } actions={ - !readonly && ( - setConfirmDelete(true)} - > - - - - Delete workflow - + draft.original && ( + <> + setConfirmDelete(true)} + > + + + + Delete workflow + + {!canDelete && ( + + Only the author or their verified agent owner can delete + this workflow. + + )} + ) } runs={ diff --git a/src/bundled/workflows/WorkflowLanding.tsx b/src/bundled/workflows/WorkflowLanding.tsx index 5d495f9bb1..a8fa43f995 100644 --- a/src/bundled/workflows/WorkflowLanding.tsx +++ b/src/bundled/workflows/WorkflowLanding.tsx @@ -62,6 +62,7 @@ import { MenuTrigger, MenuPopup, MenuItem, + MenuNote, MenuIcon, MenuRadioGroup, MenuRadioItem, @@ -85,6 +86,7 @@ import { EmptyState } from "../../shared/design-system/ui/EmptyState"; import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { messages } from "./WorkflowOperations"; import { ConfirmAction } from "./ConfirmAction"; +import { useWorkflowCanDelete } from "./useWorkflowCanDelete"; import { WorkflowRuns } from "./WorkflowRuns"; import { getWorkflowActivationWarning } from "./workflowActivationWarning"; import { @@ -546,6 +548,7 @@ function WorkflowCard({ const presentation = workflowCardPresentation(definition.yaml); const enabled = fields.enabled !== false; const readonly = definition.owner !== viewer; + const canDelete = useWorkflowCanDelete(capability, definition); const submittedOperation = operations.find( (operation) => operation.eventId === submitted?.operationId, ); @@ -562,6 +565,7 @@ function WorkflowCard({ ? "Saving is unavailable from this host." : undefined; const reasonId = useId(); + const deleteReasonId = useId(); const descriptionId = useId(); const statusId = useId(); const warning = getWorkflowActivationWarning(definition.yaml); @@ -676,8 +680,9 @@ function WorkflowCard({ Delete workflow + {!canDelete && ( + + Only the author or their verified agent owner can delete this + workflow. + + )} @@ -768,8 +779,11 @@ function WorkflowCard({ Delete workflow + {!canDelete && ( + + Only the author or their verified agent owner can delete this + workflow. + + )} @@ -1662,7 +1682,7 @@ export function WorkflowLanding({ } description={ emptyFromOwnership - ? "Workflows you own appear here. Other people’s workflows are in All channel workflows." + ? "Workflows you authored appear here. Other authors’ workflows are in All channel workflows." : emptyFromFilters ? "Try changing or clearing your filters." : "Automations that keep your community moving." diff --git a/src/bundled/workflows/WorkflowsPage.test.tsx b/src/bundled/workflows/WorkflowsPage.test.tsx index f12a3732d4..c919edc0fd 100644 --- a/src/bundled/workflows/WorkflowsPage.test.tsx +++ b/src/bundled/workflows/WorkflowsPage.test.tsx @@ -45,7 +45,7 @@ function mount( save = true, identity: Partial> = {}, ) { - const fixture = createWorkflowFixture(); + const fixture = createWorkflowFixture(identity.profiles); fixture.definitions.update({ status: "ready", data: { @@ -701,6 +701,164 @@ async function confirmDeletion(from: "grid" | "detail" = "grid") { ); } +it.each(["grid", "detail"] as const)( + "deletes an owned agent's workflow from %s while keeping edits and runs read-only", + async (from) => { + const author = keypair().pubkey; + const profiles = createProfileDirectory({ read: async () => [] }); + const listeners = new Set<() => void>(); + let owned = false; + const fixture = mount(fixtureYaml, author, true, { + profiles: { + ...profiles.queries, + owner: (key) => (owned && key === author ? fixtureViewer : undefined), + subscribe: (fn) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + }, + }); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Owner: My workflows" }), + ); + await user.click( + screen.getByRole("radio", { name: "All channel workflows" }), + ); + const card = ( + await screen.findByRole("button", { name: /^Open Message helper:/ }) + ).closest("article"); + if (!card) throw new Error("Missing workflow card"); + await user.click( + within(card).getByRole("button", { name: "Actions for Message helper" }), + ); + expect( + await screen.findByRole("menuitem", { name: "Delete workflow" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + screen.getByRole("menuitem", { name: "Delete workflow" }), + ).toHaveAccessibleDescription( + "Only the author or their verified agent owner can delete this workflow.", + ); + act(() => { + owned = true; + for (const fn of listeners) fn(); + }); + expect( + screen.getByRole("menuitem", { name: "Delete workflow" }), + ).not.toHaveAttribute("aria-disabled", "true"); + await user.keyboard("{Escape}"); + expect( + within(card).getByRole("button", { name: "Run now: Message helper" }), + ).toBeDisabled(); + if (from === "detail") { + await user.click( + within(card).getByRole("button", { name: /^Open Message helper:/ }), + ); + await user.click( + detailPane().getByRole("button", { name: "View workflow" }), + ); + expect(screen.queryByRole("button", { name: /^Run now$/ })).toBeNull(); + await user.click( + screen.getByRole("button", { name: "Workflow actions" }), + ); + await user.click( + await screen.findByRole("menuitem", { name: "Delete workflow" }), + ); + const dialog = await screen.findByRole("alertdialog", { + name: "Delete this workflow?", + }); + await user.click( + within(dialog).getByRole("button", { name: "Delete workflow" }), + ); + } else await confirmDeletion(); + expect(fixture.calls.delete).toBe(1); + expect(fixture.capability.operations.snapshot()[0]).toMatchObject({ + action: "delete", + workflow: { owner: author }, + outcome: "pending", + }); + profiles.dispose(); + }, +); + +it.each(["landing", "detail"])( + "reports ownership lost during %s deletion confirmation", + async (from) => { + const author = keypair().pubkey; + const profiles = createProfileDirectory({ read: async () => [] }); + let owned = true; + const fixture = mount(fixtureYaml, author, true, { + profiles: { + ...profiles.queries, + owner: () => (owned ? fixtureViewer : undefined), + }, + }); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Owner: My workflows" }), + ); + await user.click( + screen.getByRole("radio", { name: "All channel workflows" }), + ); + if (from === "detail") { + await user.click( + await screen.findByRole("button", { name: /^Open Message helper:/ }), + ); + await user.click( + detailPane().getByRole("button", { name: "View workflow" }), + ); + await user.click( + screen.getByRole("button", { name: "Workflow actions" }), + ); + } else { + await user.click( + await screen.findByRole("button", { + name: "Actions for Message helper", + }), + ); + } + await user.click( + await screen.findByRole("menuitem", { name: "Delete workflow" }), + ); + const dialog = await screen.findByRole("alertdialog", { + name: "Delete this workflow?", + }); + owned = false; + await user.click( + within(dialog).getByRole("button", { name: "Delete workflow" }), + ); + expect(fixture.calls.delete).toBe(0); + expect( + screen.queryByRole("alertdialog", { name: "Delete this workflow?" }), + ).toBeNull(); + expect( + await screen.findByText( + "You no longer have permission to delete this workflow. Refresh and try again.", + ), + ).toBeVisible(); + if (from === "detail") { + expect( + screen.getByRole("dialog", { name: "Message helper" }), + ).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Workflow actions" }), + ); + expect( + await screen.findByRole("menuitem", { name: "Delete workflow" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + screen.getByText( + "Only the author or their verified agent owner can delete this workflow.", + ), + ).toBeVisible(); + } else expect(screen.getByText("Couldn't start deletion")).toBeVisible(); + profiles.dispose(); + }, +); + it("confirms on the grid without mounting detail, cancels safely, and removes only after readback", async () => { const fixture = mount(); const user = userEvent.setup(); diff --git a/src/bundled/workflows/WorkflowsPage.tsx b/src/bundled/workflows/WorkflowsPage.tsx index 3a1f230d81..d84da29c1b 100644 --- a/src/bundled/workflows/WorkflowsPage.tsx +++ b/src/bundled/workflows/WorkflowsPage.tsx @@ -176,9 +176,14 @@ export function WorkflowCommunity({ openChannel(""); return; } - if (definition.owner !== viewer || !capability.availability.delete) return; deleting.current = true; try { + if (!capability.permissions.canDelete(definition)) + throw new Error( + "You no longer have permission to delete this workflow. Refresh and try again.", + ); + if (!capability.availability.delete) + throw new Error("Workflow deletion is unavailable on this connection."); capability.delete(definition); setDeleteError(undefined); } catch (cause) { diff --git a/src/bundled/workflows/fixtures.ts b/src/bundled/workflows/fixtures.ts index 4fe860bf9a..dcdcd4fc48 100644 --- a/src/bundled/workflows/fixtures.ts +++ b/src/bundled/workflows/fixtures.ts @@ -7,6 +7,7 @@ import type { WorkflowRunCursor, WorkflowDefinitions, } from "../../features/workflows/types"; +import type { ProfileQueries } from "../../features/relay/profile-directory"; export const fixtureViewer = "11".repeat(32); export const fixtureChannel = "44444444-4444-4444-8444-444444444444"; @@ -77,7 +78,9 @@ export function fixtureView(data: T) { } /** Explicit offline test capability; never used by the bundled plugin entry. */ -export function createWorkflowFixture() { +export function createWorkflowFixture( + profiles?: Pick, +) { let definitionState: ReturnType< WorkflowView["snapshot"] > = { @@ -138,6 +141,12 @@ export function createWorkflowFixture() { let dismissGate: Promise | undefined; let releaseDismiss: (() => void) | undefined; const capability: WorkflowCapability = { + permissions: { + canDelete: (workflow) => + workflow.owner === fixtureViewer || + profiles?.owner?.(workflow.owner) === fixtureViewer, + subscribe: (listener) => profiles?.subscribe(listener) ?? (() => {}), + }, availability: { definitions: true, history: true, diff --git a/src/bundled/workflows/useWorkflowCanDelete.test.tsx b/src/bundled/workflows/useWorkflowCanDelete.test.tsx new file mode 100644 index 0000000000..4f75ed40be --- /dev/null +++ b/src/bundled/workflows/useWorkflowCanDelete.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { createWorkflows } from "../../features/workflows/capability"; +import { fixtureDefinition, fixtureViewer } from "./fixtures"; +import { useWorkflowCanDelete } from "./useWorkflowCanDelete"; + +afterEach(cleanup); + +it.each(["channel access", "session closure"])( + "updates mounted delete permission after losing %s without a profile update", + (loss) => { + let allowed = true; + const owner = createWorkflows({ + reader: undefined, + viewer: fixtureViewer, + outbox: undefined, + local: undefined, + host: undefined, + canAccess: () => allowed, + }); + const { result, unmount } = renderHook(() => + useWorkflowCanDelete(owner.capability, fixtureDefinition), + ); + try { + expect(result.current).toBe(true); + act(() => { + if (loss === "session closure") owner.dispose(); + else { + allowed = false; + owner.clear(); + } + }); + expect(result.current).toBe(false); + } finally { + unmount(); + owner.dispose(); + } + }, +); diff --git a/src/bundled/workflows/useWorkflowCanDelete.ts b/src/bundled/workflows/useWorkflowCanDelete.ts new file mode 100644 index 0000000000..dad111f7dc --- /dev/null +++ b/src/bundled/workflows/useWorkflowCanDelete.ts @@ -0,0 +1,16 @@ +import { useCallback, useSyncExternalStore } from "react"; +import type { + WorkflowCapability, + WorkflowReference, +} from "../../features/workflows/types"; + +export function useWorkflowCanDelete( + capability: WorkflowCapability, + workflow: WorkflowReference | undefined, +) { + const read = useCallback( + () => !!workflow && capability.permissions.canDelete(workflow), + [capability, workflow], + ); + return useSyncExternalStore(capability.permissions.subscribe, read, read); +} diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs index e8c85f4f68..f3b07541f9 100644 --- a/src/bundled/workflows/workflows.journey.mjs +++ b/src/bundled/workflows/workflows.journey.mjs @@ -1001,10 +1001,24 @@ test("restricted cards and read-only editors keep keyboard controls without disc await editorControls(page).open("Message helper"); const dialog = page.getByRole("dialog", { name: "Message helper" }); await expect(dialog.getByText("Read-only", { exact: true })).toHaveCount(0); - // Run history is a tab, so a read-only editor has no actions menu. - await expect( - dialog.getByRole("button", { name: "Workflow actions" }), - ).toHaveCount(0); + // Read-only editors explain why Delete is unavailable in their actions menu. + const readOnlyActions = dialog.getByRole("button", { + name: "Workflow actions", + exact: true, + }); + await readOnlyActions.focus(); + await page.keyboard.press("Enter"); + const deleteAction = page.getByRole("menuitem", { + name: "Delete workflow", + exact: true, + }); + await expect(deleteAction).toBeDisabled(); + await expect(deleteAction).toHaveAccessibleDescription( + "Only the author or their verified agent owner can delete this workflow.", + ); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(readOnlyActions).toBeFocused(); await expect(dialog.getByRole("tab", { name: "Runs" })).toBeVisible(); const name = dialog.getByRole("textbox", { name: "Workflow name" }); await expect(name).toBeDisabled(); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index ef8f9c9845..a72c5166bd 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -28,7 +28,7 @@ import { createChannelDetails } from "./channel-details"; import { canonicalDetailsName } from "./channel-details-protocol"; import { channelIdentityUnsupported } from "./channel-labels"; import { createWorkflows } from "../workflows/capability"; -import { isWorkflowOperation } from "../workflows/protocol"; +import { isWorkflowOperation, workflowReference } from "../workflows/protocol"; import { createRelayReader, type Priority, @@ -1134,6 +1134,7 @@ export function createRelaySession( acceptDiscovery: (events) => channels.acceptDiscovery(events), }); const workflows = createWorkflows({ + profiles: profiles.queries, reader: transport ? verified : undefined, viewer: transport?.viewer ?? "", outbox: writes?.outbox, @@ -1392,6 +1393,15 @@ export function createRelaySession( if ([9000, 9001, 9022].includes(event.kind)) return; const id = event.tags.find(([key]) => key === "h")?.[1]; if (!id || channels.queries.get?.(id)?.viewerRole !== "guest") return; + // Workflow-coordinate deletion is an ownership action, not a contribution. + // Full command validation still runs before signing and publication. + if (event.kind === 5 && isWorkflowOperation(event)) { + const workflow = workflowReference({ + ...event, + pubkey: transport?.viewer ?? "", + }); + if (workflows.capability.permissions.canDelete(workflow)) return; + } // The relay permits a verified owner to edit/delete their agent's messages. const targets = event.tags.filter(([key]) => key === "e"); if ( diff --git a/src/features/workflows/capability.test.ts b/src/features/workflows/capability.test.ts index afee483a25..62abe668e1 100644 --- a/src/features/workflows/capability.test.ts +++ b/src/features/workflows/capability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, assert, expect, it, vi } from "vitest"; import { createWorkflows } from "./capability"; import { createOutbox, @@ -7,6 +7,14 @@ import { } from "../relay/outbox"; import { keypair, signed, flush } from "../relay/testing"; import type { RelayEvent } from "../relay/events"; +import { eventDto } from "../relay/events"; +import { + createProfileDirectory, + type ProfileQueries, +} from "../relay/profile-directory"; +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "nostr-tools/utils"; const id = "11111111-1111-4111-8111-111111111111", channelId = "22222222-2222-4222-8222-222222222222", runId = "33333333-3333-4333-8333-333333333333"; @@ -18,8 +26,12 @@ afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); -function setup(relayHttpUrl?: string) { - const key = keypair(); +function setup( + relayHttpUrl?: string, + key = keypair(), + profiles?: ProfileQueries, + initial: readonly OutgoingEvent[] = [], +) { let saved: readonly OutgoingEvent[] = [], allowed = true; let settle!: (value: string) => void, reject!: (error: Error) => void; @@ -37,7 +49,7 @@ function setup(relayHttpUrl?: string) { key.pubkey, { kinds: [30620, 46020, 5], sign, publish }, { - load: () => [], + load: () => initial, save: (rows) => { saved = structuredClone(rows); }, @@ -49,6 +61,7 @@ function setup(relayHttpUrl?: string) { ); const read = vi.fn(async () => [] as RelayEvent[]); const workflows = createWorkflows({ + profiles, viewer: key.pubkey, reader: { read }, outbox: outbox.outbox, @@ -87,6 +100,201 @@ function setup(relayHttpUrl?: string) { }, }; } +function ownedWorkflow() { + const human = keypair(), + agent = keypair(); + const attestation = [ + "auth", + human.pubkey, + "", + bytesToHex( + schnorr.sign( + createHash("sha256") + .update(`nostr:agent-auth:${agent.pubkey}:`) + .digest(), + human.secret, + ), + ), + ]; + const profile = (tags = [attestation], created_at = 1) => + eventDto( + signed(agent, { + kind: 0, + created_at, + content: JSON.stringify({ owner: human.pubkey, is_agent: true }), + tags, + }), + ); + const directory = createProfileDirectory({ read: async () => [profile()] }); + disposers.push(directory.dispose); + const h = setup(undefined, human, directory.queries); + const workflow = { ...h.definition, owner: agent.pubkey }; + return { h, human, agent, directory, profile, workflow }; +} + +it("uses hydrated verified ownership, signs as the human, and retains deletion recovery after ownership replacement", async () => { + const { h, human, agent, directory, profile, workflow } = ownedWorkflow(); + expect(h.capability.permissions.canDelete(workflow)).toBe(false); + const listener = vi.fn(); + const stop = h.capability.permissions.subscribe(listener); + h.read.mockResolvedValue([ + eventDto( + signed(agent, { + kind: 30620, + created_at: 1, + content: yaml, + tags: [ + ["h", channelId], + ["d", id], + ], + }), + ), + ]); + const view = h.capability.definitions(channelId); + await view.refresh(); + await directory.queries.ensure([agent.pubkey]); + await vi.waitFor(() => + expect(h.capability.permissions.canDelete(workflow)).toBe(true), + ); + expect(listener).toHaveBeenCalled(); + expect(() => + h.capability.save({ channelId, yaml, existing: workflow }), + ).toThrow(/owner/i); + expect(() => h.capability.trigger(workflow)).toThrow(/owner/i); + const operation = h.capability.delete(workflow); + await flush(); + const event = h.publish.mock.calls[0]?.[0]; + assert.ok(event); + expect(event.pubkey).toBe(human.pubkey); + expect(event.tags).toContainEqual(["a", `30620:${agent.pubkey}:${id}`]); + expect(() => h.validate(event)).not.toThrow(); + expect(h.capability.operations.snapshot()[0]).toMatchObject({ + eventId: operation, + workflow: { id, channelId, owner: agent.pubkey }, + action: "delete", + outcome: "pending", + }); + directory.accept([profile([], 2)]); + expect(h.capability.permissions.canDelete(workflow)).toBe(false); + expect(() => h.validate(event)).toThrow(/verified agent owner/); + expect(() => h.capability.delete(workflow)).toThrow(/owner/i); + // Revocation must not hide a command that may already have reached the relay. + expect(h.capability.operations.snapshot()[0]?.eventId).toBe(operation); + h.settle(""); + await flush(); + expect(h.capability.operations.snapshot()[0]?.outcome).toBe("unknown"); + h.read.mockResolvedValue([]); + await view.refresh(); + expect(h.capability.operations.snapshot()[0]?.outcome).toBe("succeeded"); + expect(h.publish).toHaveBeenCalledOnce(); + stop(); + view.dispose(); +}); + +it.each([ + "metadata only", + "forged attestation", + "another human", + "unverified profile", +])("denies workflow deletion with %s ownership", async (scenario) => { + const { h, agent, directory, profile, workflow } = ownedWorkflow(); + let event: RelayEvent = profile(); + if (scenario === "metadata only") event = profile([]); + if (scenario === "forged attestation") + event = profile([["auth", h.definition.owner, "", "0".repeat(128)]]); + if (scenario === "another human") { + const other = keypair(); + event = profile([ + [ + "auth", + other.pubkey, + "", + bytesToHex( + schnorr.sign( + createHash("sha256") + .update(`nostr:agent-auth:${agent.pubkey}:`) + .digest(), + other.secret, + ), + ), + ], + ]); + } + if (scenario === "unverified profile") event = { ...event }; + directory.accept([event]); + await flush(); + expect(h.capability.permissions.canDelete(workflow)).toBe(false); + expect(() => h.capability.delete(workflow)).toThrow(/owner/i); + expect(() => + h.validate({ + ...event, + pubkey: h.definition.owner, + kind: 5, + content: "", + tags: [ + ["h", channelId], + ["a", `30620:${agent.pubkey}:${id}`], + ], + }), + ).toThrow(); + expect(h.sign).not.toHaveBeenCalled(); +}); + +it("keeps own-agent rejection visible and permits a deliberate retry without enabling unrelated deletes", async () => { + const { h, directory, workflow } = ownedWorkflow(); + await directory.queries.ensure([workflow.owner]); + await vi.waitFor(() => + expect(h.capability.permissions.canDelete(workflow)).toBe(true), + ); + h.capability.delete(workflow); + await flush(); + h.reject(new PublishRejected("ownership revoked")); + await flush(); + expect(h.capability.operations.snapshot()[0]).toMatchObject({ + action: "delete", + outcome: "rejected", + workflow: { id, channelId, owner: workflow.owner }, + }); + h.capability.delete(workflow); + await flush(); + expect(h.publish).toHaveBeenCalledTimes(2); + expect(() => + h.capability.delete({ ...workflow, owner: keypair().pubkey }), + ).toThrow(/owner/i); + h.revoke(); + expect(h.capability.permissions.canDelete(workflow)).toBe(false); +}); + +it("restores an own-agent delete without current ownership evidence and never automatically republishes it", async () => { + const { h, human, directory, workflow } = ownedWorkflow(); + await directory.queries.ensure([workflow.owner]); + await vi.waitFor(() => + expect(h.capability.permissions.canDelete(workflow)).toBe(true), + ); + const operation = h.capability.delete(workflow); + await flush(); + h.settle(""); + await flush(); + const journal = h.saved(); + h.outbox.dispose(); + h.dispose(); + directory.clear(); + const restored = setup(undefined, human, directory.queries, journal); + await flush(); + expect(restored.capability.permissions.canDelete(workflow)).toBe(false); + expect(restored.capability.operations.snapshot()[0]).toMatchObject({ + eventId: operation, + action: "delete", + workflow: { id, channelId, owner: workflow.owner }, + outcome: "unknown", + }); + const view = restored.capability.definitions(channelId); + await view.refresh(); + expect(restored.capability.operations.snapshot()[0]?.outcome).toBe("unknown"); + expect(restored.publish).not.toHaveBeenCalled(); + view.dispose(); +}); + it("save preserves exact YAML/coordinate/revision, receipt success is distinct from signed head readback", async () => { const h = setup(); const view = h.capability.definitions(channelId); diff --git a/src/features/workflows/capability.ts b/src/features/workflows/capability.ts index c0fb23b66f..a71c3456d1 100644 --- a/src/features/workflows/capability.ts +++ b/src/features/workflows/capability.ts @@ -1,6 +1,7 @@ import { WORKFLOW_CHANNEL_BATCH, WORKFLOW_DEFINITION_LIMIT } from "./queries"; import type { EventData } from "../relay/events"; import type { RelayReader } from "../relay/reader"; +import type { ProfileQueries } from "../relay/profile-directory"; import type { Outbox, LocalEvents } from "../relay/outbox"; import type { WorkflowHost } from "./host"; import type { @@ -36,6 +37,7 @@ export function createWorkflows({ host, relayHttpUrl, canAccess, + profiles, notify = (listener: () => void) => listener(), }: { reader: RelayReader | undefined; @@ -46,6 +48,7 @@ export function createWorkflows({ /** Relay HTTP base for display only; hook URLs are never fetched from here. */ relayHttpUrl?: string | undefined; canAccess(channel: string): boolean; + profiles?: Pick | undefined; notify?: (listener: () => void) => void; }) { let closed = false; @@ -145,7 +148,11 @@ export function createWorkflows({ } catch { return []; } - if (workflow.owner !== viewer || !canAccess(workflow.channelId)) + if ( + item.event.pubkey !== viewer || + (item.event.kind !== 5 && workflow.owner !== viewer) || + !canAccess(workflow.channelId) + ) return []; const result = results.get(item.event.id); const outcome = @@ -362,6 +369,25 @@ export function createWorkflows({ if (!enabled) throw new Error("Workflow writes are unavailable on this connection"); } + const permissions = Object.freeze({ + canDelete(workflow: WorkflowReference) { + return ( + !closed && + canAccess(workflow.channelId) && + (workflow.owner === viewer || + profiles?.owner?.(workflow.owner) === viewer) + ); + }, + subscribe(listener: () => void) { + const stopProfiles = profiles?.subscribe(listener); + // clear() and dispose() already rebuild after access/lifetime changes. + listeners.add(listener); + return () => { + stopProfiles?.(); + listeners.delete(listener); + }; + }, + }); function send( kind: 30620 | 46020 | 5, workflow: WorkflowReference, @@ -369,7 +395,9 @@ export function createWorkflows({ revision?: string, ) { assertAccess(workflow); - if (workflow.owner !== viewer) + if ( + kind === 5 ? !permissions.canDelete(workflow) : workflow.owner !== viewer + ) throw new Error("Only the workflow owner can modify or run it"); assertOperation(kind); const tags = [ @@ -394,6 +422,7 @@ export function createWorkflows({ } const capability = Object.freeze({ availability, + permissions, definitions(channelId) { const aggregate = typeof channelId !== "string"; const channelIds = @@ -613,6 +642,10 @@ export function createWorkflows({ assertOperation(event.kind); const reference = validateWorkflowEvent(event, viewer); assertAccess(reference); + if (event.kind === 5 && !permissions.canDelete(reference)) + throw new Error( + "Only the author or their verified agent owner can delete this workflow", + ); }, receipt(event: EventData, message: string | undefined) { if (closed || !receiptInterest.delete(event.id)) return; diff --git a/src/features/workflows/protocol.test.ts b/src/features/workflows/protocol.test.ts index d374ecf603..5e39b0737b 100644 --- a/src/features/workflows/protocol.test.ts +++ b/src/features/workflows/protocol.test.ts @@ -78,6 +78,32 @@ it("strict authoring boundary rejects malformed coordinates/tags, accepts webhoo ), ).not.toThrow(); }); +it("allows human-signed canonical deletes targeting another author without allowing signer impersonation", () => { + const author = "c".repeat(64); + const event = { + ...base, + kind: 5, + content: "", + tags: [ + ["h", id], + ["a", `30620:${author}:${id}`], + ], + }; + expect(validateWorkflowEvent(event, owner)).toEqual({ + id, + channelId: id, + owner: author, + }); + expect(() => + validateWorkflowEvent({ ...event, pubkey: author }, owner), + ).toThrow(); + expect(() => + validateWorkflowEvent( + { ...event, tags: [...event.tags, ["e", base.id]] }, + owner, + ), + ).toThrow(); +}); it("YAML remains unchanged and malformed input/duplicate steps are rejected", () => { expect(workflowYaml(yaml)).toMatchObject({ enabled: false }); for (const text of [ diff --git a/src/features/workflows/protocol.ts b/src/features/workflows/protocol.ts index 81c69f7b29..30a6437827 100644 --- a/src/features/workflows/protocol.ts +++ b/src/features/workflows/protocol.ts @@ -42,7 +42,9 @@ function one(event: Pick, name: string): string { throw new Error(`Invalid workflow ${name} tag`); return tags[0][1]; } -export function workflowReference(event: EventData): WorkflowReference { +export function workflowReference( + event: Pick, +): WorkflowReference { const channelId = one(event, "h"); let id: string, owner = event.pubkey; @@ -125,7 +127,12 @@ export function validateWorkflowEvent(event: EventData, viewer: string) { if (!WORKFLOW_KINDS.includes(event.kind as 30620 | 46020 | 5)) throw new Error("Unsupported workflow operation"); const reference = workflowReference(event); - if (reference.owner !== viewer || event.pubkey !== viewer) + // Deletions may target an agent owned by the signer. Ownership is checked + // by the session capability and authoritatively by the relay, not tag shape. + if ( + (event.kind !== 5 && reference.owner !== viewer) || + event.pubkey !== viewer + ) throw new Error("Only the workflow author can manage it"); const allowed = event.kind === 5 diff --git a/src/features/workflows/session.test.ts b/src/features/workflows/session.test.ts index 5f55228aea..936a88303c 100644 --- a/src/features/workflows/session.test.ts +++ b/src/features/workflows/session.test.ts @@ -1,6 +1,9 @@ import { afterEach, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "nostr-tools/utils"; import { createRelaySession } from "../relay/session"; -import type { RelayEvent } from "../relay/events"; +import { eventDto, type RelayEvent } from "../relay/events"; import type { LiveCallbacks } from "../relay/live"; import { flush, @@ -57,6 +60,234 @@ function setup() { resolveRuns: (value: unknown) => resolveRuns(value), }; } +async function ownedWorkflowSession(role: "member" | "guest") { + const agent = keypair(), + foreign = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let incoming!: (events: readonly RelayEvent[]) => void; + const sign = vi.fn(async (template: Parameters[1]) => + signed(viewer, template), + ); + const publish = vi.fn(async () => ""); + const connection = createRelaySession( + { + ...wire.transport, + writer: { kinds: [9, 30620, 46020, 5], sign, publish }, + workflows: { runs: async () => ({ runs: [], next: null }) }, + subscribe(callbacks) { + incoming = callbacks.receive; + return { update() {}, retry() {}, dispose() {} }; + }, + }, + { outboxStorage: { load: () => [], save() {} } }, + ); + owners.push(connection); + const profile = (author: typeof agent, human: typeof viewer) => + eventDto( + signed(author, { + kind: 0, + created_at: 10, + content: "{}", + tags: [ + [ + "auth", + human.pubkey, + "", + bytesToHex( + schnorr.sign( + createHash("sha256") + .update(`nostr:agent-auth:${author.pubkey}:`) + .digest(), + human.secret, + ), + ), + ], + ], + }), + ); + connection.session.channels.ensure(channelId); + incoming([ + signed(relay, { + kind: 39002, + created_at: 1, + content: "", + tags: [ + ["d", channelId], + ["p", viewer.pubkey, "", role], + ["p", agent.pubkey, "", "bot"], + ["p", foreign.pubkey, "", "bot"], + ], + }), + profile(agent, viewer), + profile(foreign, keypair()), + ]); + await connection.session.outbox?.ready(); + const workflow = { + ...reference, + owner: agent.pubkey, + yaml: definition.content, + revision: definition.id, + createdAt: 10, + }; + await vi.waitFor(() => + expect(connection.session.workflows.permissions.canDelete(workflow)).toBe( + true, + ), + ); + return { + ...connection, + agent, + foreign, + workflow, + sign, + publish, + emit: (events: readonly RelayEvent[]) => incoming(events), + }; +} + +it.each(["member", "guest"] as const)( + "publishes a %s's owned-agent workflow deletion through the real session", + async (role) => { + const h = await ownedWorkflowSession(role); + const operation = h.session.workflows.delete(h.workflow); + await vi.waitFor(() => expect(h.publish).toHaveBeenCalledOnce()); + expect(h.sign).toHaveBeenCalledOnce(); + expect(h.sign.mock.calls[0]?.[0].tags).toContainEqual([ + "a", + `30620:${h.agent.pubkey}:${id}`, + ]); + await vi.waitFor(() => + expect(h.session.workflows.operations.snapshot()[0]).toMatchObject({ + eventId: operation, + action: "delete", + delivery: "accepted", + }), + ); + }, +); + +it("lets a Guest delete a self-authored workflow under the same deletion permission", async () => { + const h = await ownedWorkflowSession("guest"); + const workflow = { ...h.workflow, owner: viewer.pubkey }; + expect(h.session.workflows.permissions.canDelete(workflow)).toBe(true); + h.session.workflows.delete(workflow); + await vi.waitFor(() => expect(h.publish).toHaveBeenCalledOnce()); +}); + +it("keeps Guest contributions, foreign workflows and malformed deletes blocked before host signing", async () => { + const h = await ownedWorkflowSession("guest"); + const foreign = { ...h.workflow, owner: h.foreign.pubkey }; + expect(h.session.workflows.permissions.canDelete(foreign)).toBe(false); + expect(() => h.session.workflows.delete(foreign)).toThrow(); + expect(() => + h.session.workflows.save({ + channelId, + yaml: h.workflow.yaml, + existing: h.workflow, + }), + ).toThrow(); + expect(() => h.session.workflows.trigger(h.workflow)).toThrow(); + const tags = [ + ["h", channelId], + ["a", `30620:${h.agent.pubkey}:${id}`], + ]; + const commands = [ + { + kind: 5, + content: "", + tags: [ + ["h", channelId], + ["a", `30620:${h.foreign.pubkey}:${id}`], + ], + }, + { + kind: 5, + content: "", + tags: [ + ["h", channelId], + ["a", `030620:${h.agent.pubkey}:${id}`], + ], + }, + { kind: 5, content: "", tags: [...tags, ["e", definition.id]] }, + { kind: 5, content: "unexpected", tags }, + { + kind: 5, + content: "", + tags: [ + ["h", channelId], + ["a", `30023:${viewer.pubkey}:${id}`], + ], + }, + { kind: 9, content: "Guest message", tags: [["h", channelId]] }, + { + kind: 30620, + content: "name: New workflow", + tags: [ + ["h", channelId], + ["d", id], + ], + }, + { + kind: 46020, + content: "", + tags: [ + ["h", channelId], + ["d", id], + ], + }, + ]; + for (const command of commands) { + const operation = h.session.outbox?.send(command); + await vi.waitFor(() => + expect( + h.session.outbox?.snapshot().find((row) => row.event.id === operation) + ?.delivery, + ).toBe("failed"), + ); + } + expect(h.sign).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); +}); + +it.each(["ownership", "membership"] as const)( + "rechecks a Guest owner's %s after asynchronous signing", + async (revoked) => { + const h = await ownedWorkflowSession("guest"); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + h.sign.mockImplementationOnce(async (template) => { + await gate; + return signed(viewer, template); + }); + const operation = h.session.workflows.delete(h.workflow); + try { + await vi.waitFor(() => expect(h.sign).toHaveBeenCalledOnce()); + h.emit([ + revoked === "ownership" + ? signed(h.agent, { + kind: 0, + created_at: 11, + content: "{}", + tags: [], + }) + : roster(relay, channelId, [], 2), + ]); + expect(h.session.workflows.permissions.canDelete(h.workflow)).toBe(false); + } finally { + release(); + } + await vi.waitFor(() => + expect( + h.session.outbox?.snapshot().find((row) => row.event.id === operation) + ?.delivery, + ).toBe("failed"), + ); + expect(h.publish).not.toHaveBeenCalled(); + }, +); + it("workflow views start lazy and purge to unavailable before any channel/operation/view observer runs", async () => { const h = setup(); h.emit([roster(relay, channelId, [viewer.pubkey], 1)]); diff --git a/src/features/workflows/types.ts b/src/features/workflows/types.ts index 8f95ee3254..45eb97074b 100644 --- a/src/features/workflows/types.ts +++ b/src/features/workflows/types.ts @@ -86,6 +86,11 @@ export type WorkflowAvailability = Readonly<{ /** Bundled UI contract. No socket, signer, arbitrary HTTP, scheduler or approval writes. */ export interface WorkflowCapability { readonly availability: WorkflowAvailability; + /** Local verified ownership evidence; the relay authorizes every deletion. */ + readonly permissions: Readonly<{ + canDelete(workflow: WorkflowReference): boolean; + subscribe(listener: () => void): () => void; + }>; /** Arrays page a batch of up to 128 channels; single-channel detail reads stay bounded. */ definitions( channelId: string | readonly string[],