From 92e8be31c6f99e4a96ed8e0c5ab55bbed530cc90 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 12:52:59 +0000 Subject: [PATCH 1/9] feat: opt new root workspaces in to unrelated messaging by default --- .../WorkspaceUnrelatedMessagingModal.tsx | 3 +- .../App.unrelatedMessagingConsent.stories.tsx | 3 +- src/common/orpc/schemas/workspace.ts | 6 +- src/common/utils/tools/toolDefinitions.ts | 2 +- src/node/config/index.ts | 6 +- .../workspaceService.multiProject.test.ts | 5 ++ src/node/services/workspaceService.test.ts | 69 +++++++++++++++++-- src/node/services/workspaceService.ts | 25 ++++++- 8 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx index 373c6aab410..d336d9a1bc1 100644 --- a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx +++ b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx @@ -82,7 +82,8 @@ export function WorkspaceUnrelatedMessagingModal(props: WorkspaceUnrelatedMessag Applies to agents in other local chats in this Xum instance, outside this - chat's task tree. Off by default; same-tree sub-agents are unaffected. + chat's task tree. On by default for new chats; same-tree sub-agents are + unaffected.
diff --git a/src/browser/stories/App.unrelatedMessagingConsent.stories.tsx b/src/browser/stories/App.unrelatedMessagingConsent.stories.tsx index 465b86aa08f..17dc97cee06 100644 --- a/src/browser/stories/App.unrelatedMessagingConsent.stories.tsx +++ b/src/browser/stories/App.unrelatedMessagingConsent.stories.tsx @@ -69,7 +69,8 @@ export const Desktop: AppStory = { play: async ({ canvasElement }) => { const dialog = await openConsentDialog(canvasElement); const toggle = within(dialog).getByRole("switch"); - // Off by default: a fresh workspace has never consented. + // A workspace without a generation (created before the on-by-default change, or turned off) + // starts off. if (toggle.getAttribute("aria-checked") !== "false") { throw new Error("consent switch must start off for a workspace without a generation"); } diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 32a97b4ce93..47045ab0fdd 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -121,12 +121,12 @@ export const WorkflowTaskMetadataSchema = z.object({ * Shared description for the recipient-consent field on persisted config and published metadata. * The value is an opaque revocation GENERATION, not a bearer credential: the sender never supplies * it; the backend compares the generation captured at admission with the current one so an - * off→on flip cannot revive work queued under the previous consent. Absent means off. Only the - * app's settings surface writes it — same-UID processes with config access can too, so it is an + * off→on flip cannot revive work queued under the previous consent. Absent means off. New root + * workspaces are created with a fresh generation (on by default); the app's settings surface writes it — same-UID processes with config access can too, so it is an * application-level opt-in, not an isolation boundary. */ export const UNRELATED_WORKSPACE_CONSENT_DESCRIPTION = - "Opaque consent generation allowing unrelated local workspaces (other task trees in this Xum instance) to discover this workspace and send it untrusted agent messages. Absent means off; each off→on transition mints a new value, and an already-on workspace keeps its value. Never a bearer credential."; + "Opaque consent generation allowing unrelated local workspaces (other task trees in this Xum instance) to discover this workspace and send it untrusted agent messages. New root workspaces start with one; absent means off; each off→on transition mints a new value, and an already-on workspace keeps its value. Never a bearer credential."; /** * Fail-closed reader for the persisted consent generation. Config entries are loaded without diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 52909302e0a..4c266f0e8bb 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -3154,7 +3154,7 @@ export const TOOL_DEFINITIONS = { 'Send a plain-text message to another agent workspace in this Xum instance: a descendant sub-agent, a sibling/cousin, an ancestor (including the root workspace), or an unrelated workspace outside your task tree. The relationship is computed server-side — you can never claim parent authority you do not have. Same-tree peers are discoverable with task_list scope:"tree"; an unrelated workspace ID you already know (an envelope "from" reply address, or an ID the user provided) is addressable only when that recipient has opted in. ' + "Descendant targets receive trusted guidance: queued/running work is interrupted or queued at the requested boundary, and an inactive child is reawakened in the same persistent workspace under a fresh internal execution. The stable sub-agent task ID and durable role title remain unchanged, and the child's checkout is not refreshed automatically. Prefer reawakening an inactive child over spawning a replacement when its prior context or expertise is relevant. For repository-dependent work, reuse it only when the retained snapshot is appropriate or tell the child to verify and synchronize its checkout before acting; otherwise spawn a new child. If the new assignment changes the child's reusable responsibility, call task_retitle as well; do not retitle it for ordinary one-off assignments. " + "Sibling, ancestor, and unrelated targets receive your message wrapped in an untrusted envelope carrying your ID (the reply address) and relationship; sub-agent targets must have a live turn/session (peers cannot reawaken inactive targets or edit queued launch prompts — that stays parent-only), while idle root workspaces wake. Never ask a peer to do something your own constraints forbid; route such work back to the user. Peer sends are throttled (rate limits, duplicate suppression, queue and consecutive-wake caps) and refused for workflow-owned or best-of endpoints. " + - "Unrelated messaging is off by default: the actual recipient must enable it in its workspace settings; knowing its ID or its parent's consent does not grant access. Revocation cancels input not yet admitted, even after re-enabling; an already admitted turn may finish. Unrelated-message turns need user action to resume after an app restart. This tool cannot grant consent. Both endpoints must use local or worktree runtimes; SSH (including Coder), Docker, devcontainer, and unresolved runtimes are refused. Same-tree messaging is unchanged. Unrelated targets default to turn-end dispatch and keep their own agent, model, and thinking settings — your settings are never applied or persisted there. Messaging grants no additional control: your existing rights over task-tree descendants and over workspace-turn handles you already own remain exactly as before, and no other rights are added. An unrelated root that is inside a delegated workspace turn is temporarily unavailable and returns refused with a retry-after reason; retry once that turn finishes. " + + "Unrelated messaging requires the actual recipient's consent: newly created root workspaces are opted in by default, older workspaces and sub-agents only after enabling it in their workspace settings, and any workspace can turn it off; knowing its ID or its parent's consent does not grant access. Revocation cancels input not yet admitted, even after re-enabling; an already admitted turn may finish. Unrelated-message turns need user action to resume after an app restart. This tool cannot grant consent. Both endpoints must use local or worktree runtimes; SSH (including Coder), Docker, devcontainer, and unresolved runtimes are refused. Same-tree messaging is unchanged. Unrelated targets default to turn-end dispatch and keep their own agent, model, and thinking settings — your settings are never applied or persisted there. Messaging grants no additional control: your existing rights over task-tree descendants and over workspace-turn handles you already own remain exactly as before, and no other rights are added. An unrelated root that is inside a delegated workspace turn is temporarily unavailable and returns refused with a retry-after reason; retry once that turn finishes. " + "This tool does not target bash tasks, workflow runs, workspace-turn handles, or workspaces in other Xum instances.", schema: TaskSendMessageToolArgsSchema, }, diff --git a/src/node/config/index.ts b/src/node/config/index.ts index ae4250630c2..8dbe10d2431 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -4167,9 +4167,9 @@ export class Config { aiSettings: metadata.aiSettings, heartbeat: metadata.heartbeat, goalDefaults: metadata.goalDefaults, - // Carried only when the caller's metadata carries it: create/fork/child paths assemble - // metadata without consent, so a new entry never inherits it, while a re-add of an - // existing consented entry does not silently revoke it. + // Carried only when the caller's metadata carries it: fork mints a fresh generation + // (never the source's) and other callers assemble metadata without consent, while a + // re-add of an existing consented entry does not silently revoke it. unrelatedWorkspaceConsent: getValidUnrelatedWorkspaceConsent( metadata.unrelatedWorkspaceConsent ), diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 4327f329b59..32eb69e5c67 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -5,6 +5,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { tmpdir } from "node:os"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; +import { getValidUnrelatedWorkspaceConsent } from "@/common/orpc/schemas/workspace"; import { Config, type SecretsStore } from "@/node/config"; import { ContainerManager } from "@/node/multiProject/containerManager"; import { createStreamLifecycleMocks } from "@/node/services/agentSession.testHarness"; @@ -892,6 +893,10 @@ describe("WorkspaceService multi-project lifecycle", () => { { projectPath: projectAPath, projectName: "project-a" }, { projectPath: projectBPath, projectName: "project-b" }, ]); + // New root workspaces are opted in to unrelated messaging at creation. + const multiConsent = storedMultiWorkspaces[0]?.unrelatedWorkspaceConsent; + expect(multiConsent).toBeDefined(); + expect(getValidUnrelatedWorkspaceConsent(multiConsent)).toBe(multiConsent); } finally { createContainerSpy.mockRestore(); createRuntimeSpy.mockRestore(); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index b4802e221aa..05202bff7ef 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -40,6 +40,7 @@ import { tmpdir } from "os"; import path from "path"; import { Err, Ok, type Result } from "@/common/types/result"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; +import { getValidUnrelatedWorkspaceConsent } from "@/common/orpc/schemas/workspace"; import type { SendMessageError } from "@/common/types/errors"; import type { ProjectsConfig } from "@/common/types/project"; import type { Config, SecretsStore } from "@/node/config"; @@ -20947,6 +20948,56 @@ describe("WorkspaceService init cancellation", () => { } }); + test("new scratch workspaces opt in with distinct generations and a later opt-out persists", async () => { + const { + config, + historyService: scratchHistoryService, + cleanup, + } = await createTestHistoryService(); + const aiService = { + ...createStreamLifecycleMocks(), + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + try { + const workspaceService = createWorkspaceServiceForTest({ + config, + historyService: scratchHistoryService, + aiService, + }); + const first = await workspaceService.createScratch("First scratch"); + const second = await workspaceService.createScratch("Second scratch"); + if (!first.success || !second.success) { + throw new Error("Expected both scratch workspaces to be created"); + } + const firstId = first.data.metadata.id; + const secondId = second.data.metadata.id; + const consentOf = async (workspaceId: string) => + (await config.getAllWorkspaceMetadata()).find((m) => m.id === workspaceId) + ?.unrelatedWorkspaceConsent; + + const firstConsent = await consentOf(firstId); + const secondConsent = await consentOf(secondId); + // The returned metadata already carries the grant, so the UI switch starts on. + expect(first.data.metadata.unrelatedWorkspaceConsent).toBe(firstConsent); + expect(getValidUnrelatedWorkspaceConsent(firstConsent)).toBe(firstConsent); + expect(getValidUnrelatedWorkspaceConsent(secondConsent)).toBe(secondConsent); + // Each workspace owns its own revocation generation. + expect(firstConsent).not.toBe(secondConsent); + + // Opting out deletes the field; nothing re-mints it on reload (no startup backfill). + expect((await workspaceService.setUnrelatedWorkspaceConsent(firstId, false)).success).toBe( + true + ); + expect(await consentOf(firstId)).toBeUndefined(); + expect(await consentOf(secondId)).toBe(secondConsent); + } finally { + await cleanup(); + } + }); + test("scratch removal refuses to delete a workdir the workspace does not own", async () => { // A stale or hand-edited config entry can point at another chat's dir // under the scratch root; removal must not recursively delete it. @@ -21603,6 +21654,11 @@ describe("WorkspaceService init cancellation", () => { const newEntry = persisted.find((entry) => entry.id === workspaceId); expect(newEntry?.name).toBe("workspace-3"); expect(newEntry?.pendingAutoTitle).toBe(true); + // New root workspaces are opted in to unrelated messaging at creation. + expect(getValidUnrelatedWorkspaceConsent(newEntry?.unrelatedWorkspaceConsent)).toBe( + newEntry?.unrelatedWorkspaceConsent + ); + expect(newEntry?.unrelatedWorkspaceConsent).toBeDefined(); } finally { createRuntimeSpy.mockRestore(); } @@ -22424,7 +22480,7 @@ describe("WorkspaceService fork", () => { getOrCreateSessionSpy.mockRestore(); } }); - test("fork inherits a paused goal with fresh accounting but not unrelated-message consent", async () => { + test("fork inherits a paused goal with fresh accounting and gets its own unrelated-message consent", async () => { const sourceWorkspaceId = "source-workspace"; const newWorkspaceId = "forked-workspace"; const sourceProjectPath = path.join(tempDir, "project"); @@ -22534,9 +22590,14 @@ describe("WorkspaceService fork", () => { expect( metadataAfterFork.find((entry) => entry.id === sourceWorkspaceId)?.unrelatedWorkspaceConsent ).toBe("source-consent"); - expect( - metadataAfterFork.find((entry) => entry.id === newWorkspaceId)?.unrelatedWorkspaceConsent - ).toBeUndefined(); + // New root workspaces are opted in by default, but with a fresh generation: sharing the + // source's value would let a revocation on one workspace be bypassed through the other. + const forkConsent = metadataAfterFork.find( + (entry) => entry.id === newWorkspaceId + )?.unrelatedWorkspaceConsent; + expect(forkConsent).toBeDefined(); + expect(getValidUnrelatedWorkspaceConsent(forkConsent)).toBe(forkConsent); + expect(forkConsent).not.toBe("source-consent"); const forkGoal = await goalService.getGoal(newWorkspaceId); expect(forkGoal).toMatchObject({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2e57800bac2..03ac47ace3a 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1879,6 +1879,23 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick allowAgentSetGoal: true, }); +/** + * Mints a fresh unrelated-messaging consent generation (see setUnrelatedWorkspaceConsent). + * New root workspaces (create, scratch, multi-project, fork) are opted in by default so an agent + * in another task tree can reach them without a manual toggle. Pre-existing workspaces are + * deliberately not backfilled: an absent value means both "never enabled" and "turned off", so + * a backfill would silently undo explicit opt-outs. Sub-agent children are created by + * TaskService and stay off; their parent owns them. + */ +function mintUnrelatedWorkspaceConsent(): string { + const generation = crypto.randomUUID(); + assert( + getValidUnrelatedWorkspaceConsent(generation) === generation, + "minted unrelated-workspace consent must satisfy the fail-closed reader" + ); + return generation; +} + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly sessions = new Map(); @@ -5327,6 +5344,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { title, createdAt, runtimeConfig: { type: "local" }, + unrelatedWorkspaceConsent: mintUnrelatedWorkspaceConsent(), }); config.projects.set(SCRATCH_PROJECT_CONFIG_KEY, scratchProject); return config; @@ -5650,6 +5668,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { createdAt: metadata.createdAt, runtimeConfig: finalRuntimeConfig, subProjectPath: effectiveSubProjectPath, + unrelatedWorkspaceConsent: mintUnrelatedWorkspaceConsent(), // Persist tags atomically with creation so orchestration loops that // look workspaces up by tag (e.g. workspace.ensure) never observe a // created-but-untagged window after a crash. @@ -6074,6 +6093,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { createdAt, runtimeConfig: finalRuntimeConfig, projects: normalizedProjects, + unrelatedWorkspaceConsent: mintUnrelatedWorkspaceConsent(), }); config.projects.set(MULTI_PROJECT_CONFIG_KEY, multiProjectConfig); return config; @@ -7487,7 +7507,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } // Off (or malformed) → on: a NEW generation, so nothing admitted under an earlier // consent can be revived by re-enabling. - entry.unrelatedWorkspaceConsent = crypto.randomUUID(); + entry.unrelatedWorkspaceConsent = mintUnrelatedWorkspaceConsent(); return freshConfig; }); if (!outcome.success) { @@ -11239,6 +11259,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { namedWorkspacePath, // Preserve sub-project cwd/prompt context when forking via /fork. subProjectPath: sourceMetadata.subProjectPath, + // A fork is a new root workspace: opted in with its OWN generation, never the source's, + // so revoking one workspace's consent cannot be bypassed through the other. + unrelatedWorkspaceConsent: mintUnrelatedWorkspaceConsent(), // Forks with a continue message stay pending until the first accepted user send // can generate a more specific title, unless the user edits the title first. pendingAutoTitle: pendingAutoTitle === true ? true : undefined, From 0ae0703f928b6aec18e7c2ede298f777d5f5cb1d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 13:11:18 +0000 Subject: [PATCH 2/9] fix: scope consent dialog copy to top-level chats --- .../WorkspaceUnrelatedMessagingModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx index d336d9a1bc1..b2bb9953aed 100644 --- a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx +++ b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx @@ -82,8 +82,8 @@ export function WorkspaceUnrelatedMessagingModal(props: WorkspaceUnrelatedMessag
Applies to agents in other local chats in this Xum instance, outside this - chat's task tree. On by default for new chats; same-tree sub-agents are - unaffected. + chat's task tree. On by default for new top-level chats; same-tree sub-agents + are unaffected.
From 6a103c800f23f4574b0eb06860fc53e78258c20d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 13:29:48 +0000 Subject: [PATCH 3/9] fix: grant default consent only after registration-time plugin sanitization --- src/node/services/workspaceService.test.ts | 49 ++++++++++++++++- src/node/services/workspaceService.ts | 64 ++++++++++++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 05202bff7ef..0299e10b887 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -21624,6 +21624,24 @@ describe("WorkspaceService init cancellation", () => { // Skip the background init path so the test stays focused on auto-naming/persistence. removingWorkspaces.add(workspaceId); + // Record the persisted consent while registration-time sanitization runs. + const consentDuringSanitize: unknown[] = []; + spyOn( + workspaceService as unknown as { + sanitizeStalePluginOverridesForNewWorkspace: ( + workspaceId: string, + workspacePath: string + ) => Promise; + }, + "sanitizeStalePluginOverridesForNewWorkspace" + ).mockImplementation((id: string) => { + consentDuringSanitize.push( + configState.projects.get(projectPath)?.workspaces.find((entry) => entry.id === id) + ?.unrelatedWorkspaceConsent + ); + return Promise.resolve(undefined); + }); + const result = await workspaceService.create( projectPath, // No branchName — backend should auto-generate workspace-3. @@ -21654,11 +21672,16 @@ describe("WorkspaceService init cancellation", () => { const newEntry = persisted.find((entry) => entry.id === workspaceId); expect(newEntry?.name).toBe("workspace-3"); expect(newEntry?.pendingAutoTitle).toBe(true); - // New root workspaces are opted in to unrelated messaging at creation. + // New root workspaces are opted in to unrelated messaging at creation, but only after + // registration-time sanitization; the announced metadata carries the same generation. + expect(consentDuringSanitize).toEqual([undefined]); expect(getValidUnrelatedWorkspaceConsent(newEntry?.unrelatedWorkspaceConsent)).toBe( newEntry?.unrelatedWorkspaceConsent ); expect(newEntry?.unrelatedWorkspaceConsent).toBeDefined(); + expect(result.data.metadata.unrelatedWorkspaceConsent).toBe( + newEntry?.unrelatedWorkspaceConsent + ); } finally { createRuntimeSpy.mockRestore(); } @@ -22578,6 +22601,24 @@ describe("WorkspaceService fork", () => { }) ); + // Record what other task trees could see while registration-time sanitization runs. + const consentDuringSanitize: unknown[] = []; + const sanitizeSpy = spyOn( + workspaceService as unknown as { + sanitizeStalePluginOverridesForNewWorkspace: ( + workspaceId: string, + workspacePath: string + ) => Promise; + }, + "sanitizeStalePluginOverridesForNewWorkspace" + ).mockImplementation(async (workspaceId: string) => { + consentDuringSanitize.push( + (await config.getAllWorkspaceMetadata()).find((entry) => entry.id === workspaceId) + ?.unrelatedWorkspaceConsent + ); + return undefined; + }); + try { const result = await workspaceService.fork(sourceWorkspaceId, "fork-child"); @@ -22585,6 +22626,9 @@ describe("WorkspaceService fork", () => { if (!result.success) { throw new Error(`Expected success result, got error: ${result.error}`); } + // Consent is granted only after sanitization: while it runs the fork is registered but + // must not be discoverable or wakeable by unrelated agents. + expect(consentDuringSanitize).toEqual([undefined]); const metadataAfterFork = await config.getAllWorkspaceMetadata(); expect( @@ -22598,6 +22642,8 @@ describe("WorkspaceService fork", () => { expect(forkConsent).toBeDefined(); expect(getValidUnrelatedWorkspaceConsent(forkConsent)).toBe(forkConsent); expect(forkConsent).not.toBe("source-consent"); + // The announced metadata matches what was persisted, so the UI switch starts on. + expect(result.data.metadata.unrelatedWorkspaceConsent).toBe(forkConsent); const forkGoal = await goalService.getGoal(newWorkspaceId); expect(forkGoal).toMatchObject({ @@ -22617,6 +22663,7 @@ describe("WorkspaceService fork", () => { turnsUsed: 1, }); } finally { + sanitizeSpy.mockRestore(); orchestrateForkSpy.mockRestore(); copyPlanSpy.mockRestore(); runBackgroundInitSpy.mockRestore(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 03ac47ace3a..5a11885e548 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1882,7 +1882,9 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick /** * Mints a fresh unrelated-messaging consent generation (see setUnrelatedWorkspaceConsent). * New root workspaces (create, scratch, multi-project, fork) are opted in by default so an agent - * in another task tree can reach them without a manual toggle. Pre-existing workspaces are + * in another task tree can reach them without a manual toggle; create and fork grant it only + * after registration-time plugin sanitization (grantCreationUnrelatedWorkspaceConsent), while + * scratch and multi-project have no such step and persist it with the entry. Pre-existing workspaces are * deliberately not backfilled: an absent value means both "never enabled" and "turned off", so * a backfill would silently undo explicit opt-outs. Sub-agent children are created by * TaskService and stay off; their parent owns them. @@ -5668,7 +5670,6 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { createdAt: metadata.createdAt, runtimeConfig: finalRuntimeConfig, subProjectPath: effectiveSubProjectPath, - unrelatedWorkspaceConsent: mintUnrelatedWorkspaceConsent(), // Persist tags atomically with creation so orchestration loops that // look workspaces up by tag (e.g. workspace.ensure) never observe a // created-but-untagged window after a crash. @@ -5720,6 +5721,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); } } + // Registration is complete (sanitized when required) and nothing has been announced + // yet: only now may other task trees discover and message this workspace. + const unrelatedWorkspaceConsent = await this.grantCreationUnrelatedWorkspaceConsent( + owningProjectPath, + workspaceId, + createResult!.workspacePath + ); + completeMetadata = { ...completeMetadata, unrelatedWorkspaceConsent }; } finally { await releaseRegistrationLock?.(); this.pendingPluginSanitizations.delete(workspaceId); @@ -7525,6 +7534,46 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } } + /** + * Opts a newly created root workspace in to unrelated messaging. Callers run this only once + * the registration is complete, i.e. after registration-time plugin-override sanitization: + * consent makes the entry discoverable (task_list scope:"instance" reads config directly) and + * wakeable by other task trees, and an agent request during the sanitization window would + * activate the stale plugin enable that sanitization exists to prune. Fails closed: if the + * write fails the workspace simply stays off. Returns the persisted generation, if any. + */ + private async grantCreationUnrelatedWorkspaceConsent( + projectPath: string, + workspaceId: string, + workspacePath: string + ): Promise { + let granted: string | undefined; + try { + await this.config.editConfig((freshConfig) => { + const entry = this.findFreshWorkspaceEntry(freshConfig, { + projectPath, + workspaceId, + workspacePath, + }); + if (!entry) { + return freshConfig; + } + granted = + getValidUnrelatedWorkspaceConsent(entry.unrelatedWorkspaceConsent) ?? + mintUnrelatedWorkspaceConsent(); + entry.unrelatedWorkspaceConsent = granted; + return freshConfig; + }); + } catch (error) { + log.warn("Failed to grant default unrelated-workspace consent; leaving it off", { + workspaceId, + error: getErrorMessage(error), + }); + return undefined; + } + return granted; + } + async setHeartbeatSettings( workspaceId: string, settings: WorkspaceHeartbeatSettingsUpdate @@ -11259,9 +11308,6 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { namedWorkspacePath, // Preserve sub-project cwd/prompt context when forking via /fork. subProjectPath: sourceMetadata.subProjectPath, - // A fork is a new root workspace: opted in with its OWN generation, never the source's, - // so revoking one workspace's consent cannot be bypassed through the other. - unrelatedWorkspaceConsent: mintUnrelatedWorkspaceConsent(), // Forks with a continue message stay pending until the first accepted user send // can generate a more specific title, unless the user edits the title first. pendingAutoTitle: pendingAutoTitle === true ? true : undefined, @@ -11347,6 +11393,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); } } + // A fork is a new root workspace: opted in with its OWN generation (the metadata above + // never copies the source's, so revoking one cannot be bypassed through the other), and + // only once registration-time sanitization has succeeded. + metadata.unrelatedWorkspaceConsent = await this.grantCreationUnrelatedWorkspaceConsent( + foundProjectPath, + newWorkspaceId, + workspacePath + ); } finally { await releaseRegistrationLock?.(); this.pendingPluginSanitizations.delete(newWorkspaceId); From 9836ac075e980f39fdc11347ea9cc444dc1aefe0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 14:33:37 +0000 Subject: [PATCH 4/9] fix: grant default consent after delegated-turn reservation and after fork setup --- .../services/taskWorkspaceSeam.testUtils.ts | 1 + src/node/services/taskWorkspaceSeam.ts | 4 +- src/node/services/workspaceService.test.ts | 17 +++++ src/node/services/workspaceService.ts | 73 ++++++++++++++----- ...eService.unrelatedWorkspaceConsent.test.ts | 35 +++++++++ .../services/workspaceTurnManager.test.ts | 45 ++++++++++++ src/node/services/workspaceTurnManager.ts | 16 +++- 7 files changed, 170 insertions(+), 21 deletions(-) diff --git a/src/node/services/taskWorkspaceSeam.testUtils.ts b/src/node/services/taskWorkspaceSeam.testUtils.ts index af05d87b48a..ada3b1dcfac 100644 --- a/src/node/services/taskWorkspaceSeam.testUtils.ts +++ b/src/node/services/taskWorkspaceSeam.testUtils.ts @@ -60,6 +60,7 @@ export function makeWorkspaceHostFake(overrides: Partial = {}): W remove: () => Promise.resolve(Ok(undefined)), removeWhileTaskTreeLocked: () => Promise.resolve(Ok(undefined)), create: () => Promise.resolve(Err("workspaceHost.create not mocked")), + grantDefaultUnrelatedWorkspaceConsent: () => Promise.resolve(), // Task-create tests exercise launch flow, not plugin-override sanitization. sanitizeMaterializedTaskWorkspace: () => Promise.resolve(undefined), discardExtensionMetadataEntry: () => Promise.resolve(), diff --git a/src/node/services/taskWorkspaceSeam.ts b/src/node/services/taskWorkspaceSeam.ts index 68624d9f430..4dc37b0da17 100644 --- a/src/node/services/taskWorkspaceSeam.ts +++ b/src/node/services/taskWorkspaceSeam.ts @@ -664,8 +664,10 @@ export interface WorkspaceProvisioningHost { subProjectPath?: string, pendingAutoTitle?: boolean, tags?: Record, - options?: { awaitMaterialization?: boolean } + options?: { awaitMaterialization?: boolean; deferUnrelatedWorkspaceConsent?: boolean } ): Promise>; + /** Default unrelated-messaging consent for a target created with the grant deferred. */ + grantDefaultUnrelatedWorkspaceConsent(workspaceId: string): Promise; sanitizeMaterializedTaskWorkspace( workspaceId: string, workspacePath: string, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 0299e10b887..14d8ce2857c 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -22601,6 +22601,20 @@ describe("WorkspaceService fork", () => { }) ); + // Record what other task trees could see while goal inheritance (post-registration fork + // setup) runs; the real inheritance still executes. + const consentDuringGoalInheritance: unknown[] = []; + const originalInheritFromFork = goalService.inheritFromFork.bind(goalService); + const inheritSpy = spyOn(goalService, "inheritFromFork").mockImplementation( + async (sourceId: string, targetId: string) => { + consentDuringGoalInheritance.push( + (await config.getAllWorkspaceMetadata()).find((entry) => entry.id === targetId) + ?.unrelatedWorkspaceConsent + ); + return originalInheritFromFork(sourceId, targetId); + } + ); + // Record what other task trees could see while registration-time sanitization runs. const consentDuringSanitize: unknown[] = []; const sanitizeSpy = spyOn( @@ -22629,6 +22643,8 @@ describe("WorkspaceService fork", () => { // Consent is granted only after sanitization: while it runs the fork is registered but // must not be discoverable or wakeable by unrelated agents. expect(consentDuringSanitize).toEqual([undefined]); + // ...nor while the rest of the fork's setup (goal inheritance) is still running. + expect(consentDuringGoalInheritance).toEqual([undefined]); const metadataAfterFork = await config.getAllWorkspaceMetadata(); expect( @@ -22663,6 +22679,7 @@ describe("WorkspaceService fork", () => { turnsUsed: 1, }); } finally { + inheritSpy.mockRestore(); sanitizeSpy.mockRestore(); orchestrateForkSpy.mockRestore(); copyPlanSpy.mockRestore(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5a11885e548..329c16c9045 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1882,9 +1882,10 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick /** * Mints a fresh unrelated-messaging consent generation (see setUnrelatedWorkspaceConsent). * New root workspaces (create, scratch, multi-project, fork) are opted in by default so an agent - * in another task tree can reach them without a manual toggle; create and fork grant it only - * after registration-time plugin sanitization (grantCreationUnrelatedWorkspaceConsent), while - * scratch and multi-project have no such step and persist it with the entry. Pre-existing workspaces are + * in another task tree can reach them without a manual toggle. create grants it after + * registration-time plugin sanitization, fork after all of its setup, and delegated targets after + * WorkspaceTurnManager reserves their handle (grantCreationUnrelatedWorkspaceConsent); scratch and + * multi-project have no such steps and persist it with the entry. Pre-existing workspaces are * deliberately not backfilled: an absent value means both "never enabled" and "turned off", so * a backfill would silently undo explicit opt-outs. Sub-agent children are created by * TaskService and stay off; their parent owns them. @@ -5388,6 +5389,13 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * read the checkout right after create() (and cannot wait for init) opt out. */ awaitMaterialization?: boolean; + /** + * Skip the default unrelated-messaging consent grant. WorkspaceTurnManager sets this + * for delegated targets and grants via grantDefaultUnrelatedWorkspaceConsent once its + * handle reservation exists, so no other task tree can wake the target before the + * delegated turn owns it. + */ + deferUnrelatedWorkspaceConsent?: boolean; } ): Promise> { if (tags != null) { @@ -5723,12 +5731,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } // Registration is complete (sanitized when required) and nothing has been announced // yet: only now may other task trees discover and message this workspace. - const unrelatedWorkspaceConsent = await this.grantCreationUnrelatedWorkspaceConsent( - owningProjectPath, - workspaceId, - createResult!.workspacePath - ); - completeMetadata = { ...completeMetadata, unrelatedWorkspaceConsent }; + if (options?.deferUnrelatedWorkspaceConsent !== true) { + const unrelatedWorkspaceConsent = await this.grantCreationUnrelatedWorkspaceConsent( + owningProjectPath, + workspaceId, + createResult!.workspacePath + ); + completeMetadata = { ...completeMetadata, unrelatedWorkspaceConsent }; + } } finally { await releaseRegistrationLock?.(); this.pendingPluginSanitizations.delete(workspaceId); @@ -7540,7 +7550,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * consent makes the entry discoverable (task_list scope:"instance" reads config directly) and * wakeable by other task trees, and an agent request during the sanitization window would * activate the stale plugin enable that sanitization exists to prune. Fails closed: if the - * write fails the workspace simply stays off. Returns the persisted generation, if any. + * edit throws the workspace simply stays off. Returns the granted generation, if any. */ private async grantCreationUnrelatedWorkspaceConsent( projectPath: string, @@ -7571,9 +7581,34 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }); return undefined; } + // No post-write re-read: a swallowed save failure (Config.saveConfig logs and continues) + // leaves the edited object in the in-process config snapshot, so the settings switch, + // task_list discovery and send admission all read the same value; only a restart differs. + // That behavior is shared by every config edit, including setUnrelatedWorkspaceConsent. return granted; } + /** + * Deferred default consent for a workspace created with `deferUnrelatedWorkspaceConsent`. + * Called by WorkspaceTurnManager once the delegated turn's handle reservation is installed, + * so unrelated senders see a delegated root (refused) rather than an idle one. Publishes the + * resulting metadata so the settings switch reflects the grant. + */ + async grantDefaultUnrelatedWorkspaceConsent(workspaceId: string): Promise { + const found = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId); + if (found == null) { + return; + } + const granted = await this.grantCreationUnrelatedWorkspaceConsent( + found.projectPath, + workspaceId, + found.workspace.path + ); + if (granted != null) { + await this.emitCurrentWorkspaceMetadata(workspaceId); + } + } + async setHeartbeatSettings( workspaceId: string, settings: WorkspaceHeartbeatSettingsUpdate @@ -11393,14 +11428,6 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); } } - // A fork is a new root workspace: opted in with its OWN generation (the metadata above - // never copies the source's, so revoking one cannot be bypassed through the other), and - // only once registration-time sanitization has succeeded. - metadata.unrelatedWorkspaceConsent = await this.grantCreationUnrelatedWorkspaceConsent( - foundProjectPath, - newWorkspaceId, - workspacePath - ); } finally { await releaseRegistrationLock?.(); this.pendingPluginSanitizations.delete(newWorkspaceId); @@ -11445,6 +11472,16 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }); } + // A fork is a new root workspace: opted in with its OWN generation (the metadata above + // never copies the source's, so revoking one cannot be bypassed through the other). Granted + // last, once sanitization, goal inheritance and the pending branch-summary marker are all in + // place, so an unrelated agent's first send cannot race any of that setup. + metadata.unrelatedWorkspaceConsent = await this.grantCreationUnrelatedWorkspaceConsent( + foundProjectPath, + newWorkspaceId, + workspacePath + ); + const enrichedMetadata = this.enrichFrontendMetadata(metadata); session.emitMetadata(enrichedMetadata); diff --git a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts index 3d48061b87e..49172076984 100644 --- a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts +++ b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts @@ -293,6 +293,41 @@ describe("WorkspaceService.setUnrelatedWorkspaceConsent", () => { }); }); +describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { + let harness: Awaited>; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + mock.restore(); + await harness.cleanup(); + }); + + test("persists a generation for that workspace only and publishes it", async () => { + const published: Array<{ + workspaceId: string; + metadata: { unrelatedWorkspaceConsent?: string } | null; + }> = []; + harness.service.on( + "metadata", + (event: { workspaceId: string; metadata: { unrelatedWorkspaceConsent?: string } | null }) => + published.push(event) + ); + + await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + + const generation = harness.persistedConsent(); + expect(typeof generation).toBe("string"); + expect(getValidUnrelatedWorkspaceConsent(generation)).toBe(generation as string); + expect(harness.persistedConsent(OTHER_WORKSPACE_ID)).toBeUndefined(); + expect(published).toHaveLength(1); + expect(published[0].workspaceId).toBe(WORKSPACE_ID); + expect(published[0].metadata?.unrelatedWorkspaceConsent).toBe(generation as string); + }); +}); + describe("getValidUnrelatedWorkspaceConsent", () => { test("accepts only non-empty, whitespace-free opaque strings", () => { expect(getValidUnrelatedWorkspaceConsent("3b6a1f9e-2c4d-4e8f-9a0b-1c2d3e4f5a6b")).toBe( diff --git a/src/node/services/workspaceTurnManager.test.ts b/src/node/services/workspaceTurnManager.test.ts index f285ca625c4..c48ffc5f9c0 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -2071,6 +2071,51 @@ describe("WorkspaceTurnManager", () => { }); }); + test("createWorkspaceTurn defers default unrelated-messaging consent until its handle is reserved", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["childworkspace", "turnhandle"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); + // What an unrelated sender's guards would see at the moment consent is granted. + let registrationAtGrant: unknown = "not granted"; + const managerRef: { + current?: ReturnType["taskService"]; + } = {}; + const grantDefaultUnrelatedWorkspaceConsent = mock((workspaceId: string) => { + registrationAtGrant = managerRef.current?.getLiveWorkspaceTurnRegistration(workspaceId); + return Promise.resolve(); + }); + const workspaceMocks = createWorkspaceServiceMocks({ + create: createWorkspace, + grantDefaultUnrelatedWorkspaceConsent, + }); + const { taskService } = createWorkspaceTurnManagerHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + managerRef.current = taskService; + + const result = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize the repo", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + + expect(result.success).toBe(true); + // create() must not grant it: the target would be an idle, wakeable root before this turn + // owns it. + const createCall = createWorkspace.mock.calls[0] as unknown[]; + expect(createCall[8]).toMatchObject({ deferUnrelatedWorkspaceConsent: true }); + // Granted exactly once, while the reservation already marks it as a delegated root. + expect(grantDefaultUnrelatedWorkspaceConsent).toHaveBeenCalledTimes(1); + expect(grantDefaultUnrelatedWorkspaceConsent.mock.calls[0]).toEqual(["childworkspace"]); + expect(registrationAtGrant).toMatchObject({ + handleId: "wst_childworkspace", + ownerWorkspaceId: parentId, + }); + }); + test("createWorkspaceTurn launches a new workspace with an explicit agent id", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["childworkspace", "turnhandle"]); diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index 70a12fabfdd..bd8dc63202d 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -1232,8 +1232,9 @@ export class WorkspaceTurnManager { false, tags, // The agentId validation below reads the target checkout under the task mutex, so - // a local worktree must be populated before create() resolves. - { awaitMaterialization: true } + // a local worktree must be populated before create() resolves. Default consent is + // granted below, once this turn's handle reservation exists (see create()). + { awaitMaterialization: true, deferUnrelatedWorkspaceConsent: true } ); if (!createResult.success) { return Err(`Task.createWorkspaceTurn: workspace create failed (${createResult.error})`); @@ -1464,6 +1465,17 @@ export class WorkspaceTurnManager { } return Err("Task.createWorkspaceTurn: owner workspace was archived during turn creation"); } + if (createdWorkspace) { + // New root workspaces are opted in to unrelated messaging by default. Granted only now: + // the handle reservation above makes unrelated senders see a delegated root (refused, + // and hidden from task_list scope:"instance") instead of an idle one they could wake + // before this turn owns the workspace. + assert( + this.activeWorkspaceTurnHandleByWorkspaceId.get(targetWorkspaceId)?.handleId === handleId, + "createWorkspaceTurn: a created target's handle reservation must exist before consent" + ); + await this.workspaceService.grantDefaultUnrelatedWorkspaceConsent(targetWorkspaceId); + } if (agentValidationError != null) { // Deferred post-create validation failure: the record above keeps the created // workspace owner-owned (retryable via mode="existing"); settle the handle as a From df1bf424e1740f821dfcef0a532575bb4acfd778 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 15:15:19 +0000 Subject: [PATCH 5/9] docs: align task_send_message/task_list consent wording with the new default --- docs/hooks/tools.mdx | 10 +++++----- src/common/utils/tools/toolDefinitions.ts | 4 ++-- .../agentSkills/builtInSkillContent.generated.ts | 10 +++++----- src/node/services/tools/task_list.ts | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 77234a125de..0a09f2fbcf6 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -808,11 +808,11 @@ If a value is too large for the environment, it may be omitted (not set). Xum al
task_send_message (3) -| Env var | JSON path | Type | Description | -| ------------------------------------ | --------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. | -| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). | -| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must opt in through its workspace settings. | +| Env var | JSON path | Type | Description | +| ------------------------------------ | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. | +| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). | +| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces are opted in by default; others enable it in their workspace settings). |
diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 4c266f0e8bb..ed6f8238a60 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -1073,7 +1073,7 @@ export const TaskSendMessageToolArgsSchema = z .string() .min(1) .describe( - 'Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must opt in through its workspace settings.' + 'Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces are opted in by default; others enable it in their workspace settings).' ), message: z .string() @@ -3212,7 +3212,7 @@ export const TOOL_DEFINITIONS = { "When recovering an uncertain workflow_run, omit statuses first or include pending/running/backgrounded as well as interrupted/failed/completed; terminal-only filters can hide unfinished workflow runs. Pending runs may need workflow_resume because no runner may be active yet. " + "Workflow rows may include compact `workflowProgress` so callers can see the latest phase before deciding whether to await, resume, or leave the run alone. " + 'Pass scope:"tree" to list every agent workspace in this task tree instead — ancestors, siblings/cousins, descendants, and the root workspace row (status "workspace") — each tagged with its relationship to you. Tree rows are addressable via task_send_message except your own "self" row, best-of candidate rows (`bestOf` metadata, refused to keep candidates independent), and non-descendant rows in terminal states (peers cannot reactivate an inactive task — only its parent can); the root row is included by default and filtered like any other row when explicit statuses are passed. ' + - 'Pass scope:"instance" from a local/worktree workspace for the on-demand address book of this Xum instance: eligible local/worktree root workspaces across projects (status "workspace", never another tree\'s sub-agents), tagged self, ancestor, or unrelated, ordered newest first by createdAt. Unrelated roots must opt in through their workspace settings; absent or revoked consent hides them before searching, counting, and paging. Consent does not hide your own task-tree root. Narrow with `query` (ID, title, name, project path), page with `limit`/`offset`, and continue from `nextOffset` when it is returned; `activity` (busy/idle) is a snapshot taken at listing time. Rows are addressable via task_send_message — unrelated targets receive your text as an untrusted agent message, queued to turn-end while they are busy, under their own agent/model settings; discovery grants no additional control, and existing ownership rights remain unchanged. ' + + 'Pass scope:"instance" from a local/worktree workspace for the on-demand address book of this Xum instance: eligible local/worktree root workspaces across projects (status "workspace", never another tree\'s sub-agents), tagged self, ancestor, or unrelated, ordered newest first by createdAt. Unrelated roots must have consented (newly created roots are opted in by default; others enable it in their workspace settings); absent or revoked consent hides them before searching, counting, and paging. Consent does not hide your own task-tree root. Narrow with `query` (ID, title, name, project path), page with `limit`/`offset`, and continue from `nextOffset` when it is returned; `activity` (busy/idle) is a snapshot taken at listing time. Rows are addressable via task_send_message — unrelated targets receive your text as an untrusted agent message, queued to turn-end while they are busy, under their own agent/model settings; discovery grants no additional control, and existing ownership rights remain unchanged. ' + "The legacy includeArchived option only affects archived workspace-turn and bash records; sub-agents remain one inactive/active task identity. " + "This is a discovery tool, NOT a waiting mechanism. If the current request actually depends on a task's output, call task_await with the specific task IDs you need; do not await all active tasks just because they appear here.", schema: TaskListToolArgsSchema, diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 409013b623f..c975c078398 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6975,11 +6975,11 @@ export const BUILTIN_SKILL_FILES: Record> = { "
", "task_send_message (3)", "", - "| Env var | JSON path | Type | Description |", - "| ------------------------------------ | --------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", - "| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. |", - '| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). |', - '| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must opt in through its workspace settings. |', + "| Env var | JSON path | Type | Description |", + "| ------------------------------------ | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |", + "| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. |", + '| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). |', + '| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces are opted in by default; others enable it in their workspace settings). |', "", "
", "", diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index 25c82fe159a..97b4d316bd6 100644 --- a/src/node/services/tools/task_list.ts +++ b/src/node/services/tools/task_list.ts @@ -86,7 +86,7 @@ const TREE_SCOPE_RESTRICTED_NOTE = "This workspace cannot send or receive peer messages (best-of candidates stay independent; workflow-owned tasks communicate through the workflow journal), so only self/descendant rows are listed; descendants remain addressable via task_send_message guidance."; const INSTANCE_SCOPE_NOTE = - "Rows are local/worktree root workspaces in this Xum instance and an availability snapshot. Unrelated roots must opt in through their workspace settings; absent or revoked consent hides them, including from searches and counts. Non-local or unresolved runtimes, archived, user-stopped, stopping, and delegated-turn roots are omitted, and a listed target can still refuse if its state changes (including consent, runtime, or a delegated turn starting on it). " + + "Rows are local/worktree root workspaces in this Xum instance and an availability snapshot. Unrelated roots must have consented (newly created roots are opted in by default; others enable it in their workspace settings); absent or revoked consent hides them, including from searches and counts. Non-local or unresolved runtimes, archived, user-stopped, stopping, and delegated-turn roots are omitted, and a listed target can still refuse if its state changes (including consent, runtime, or a delegated turn starting on it). " + 'Message them with task_send_message — "unrelated" rows receive your text as an untrusted agent message, queued to turn-end while they are busy, under their own agent/model settings; discovery grants no additional control. Your own "self" row is not addressable.'; // The tree note promises self/descendant rows, which would be false here: a restricted caller From 60a984c0b03c6a57c703a21e751fe771ac8882c2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 15:30:18 +0000 Subject: [PATCH 6/9] fix: an explicit consent toggle cancels a pending deferred default --- src/node/services/workspaceService.test.ts | 177 ++++++++++++++++++ src/node/services/workspaceService.ts | 47 +++-- ...eService.unrelatedWorkspaceConsent.test.ts | 42 +++++ 3 files changed, 253 insertions(+), 13 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 14d8ce2857c..f163c9585ed 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -21687,6 +21687,183 @@ describe("WorkspaceService init cancellation", () => { } }); + test("create() with deferUnrelatedWorkspaceConsent leaves consent off and marks the default pending", async () => { + // /new mirrors /fork's seamless flow: callers no longer have to invent a + // workspace name. The backend should derive the next "workspace-N" slot + // and persist `pendingAutoTitle` so the first message can title the workspace. + const workspaceId = "ws-auto-named"; + const projectPath = "/tmp/proj-auto"; + const workspacePath = "/tmp/proj-auto/workspace-3"; + + const initStates = new Map(); + const mockInitStateManager: Partial = { + on: mock(() => undefined as unknown as InitStateManager), + startInit: mock((id: string) => { + initStates.set(id, { + status: "running", + hookPath: projectPath, + startTime: 0, + lines: [], + exitCode: null, + endTime: null, + }); + }), + getInitState: mock((id: string) => initStates.get(id)), + clearInMemoryState: mock((id: string) => { + initStates.delete(id); + }), + }; + + const configState: ProjectsConfig = { projects: new Map() }; + + const mockMetadata: FrontendWorkspaceMetadata = { + id: workspaceId, + name: "workspace-3", + projectName: "proj-auto", + projectPath, + createdAt: "2026-01-01T00:00:00.000Z", + namedWorkspacePath: workspacePath, + runtimeConfig: { type: "local" }, + pendingAutoTitle: true, + }; + + const mockConfig: MockWorkspaceConfig = { + rootDir: "/tmp/mux-root", + srcDir: "/tmp/src", + generateStableId: mock(() => workspaceId), + editConfig: mock((editFn: (config: ProjectsConfig) => ProjectsConfig) => { + editFn(configState); + return Promise.resolve(); + }), + getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), + sessionsDir: "/tmp/test/sessions", + findWorkspace: mock(() => null), + // Two pre-existing workspaces — auto-naming should skip past them. + loadConfigOrDefault: mock(() => ({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { id: "x", name: "workspace-1", path: "/tmp/proj-auto/workspace-1" }, + { id: "y", name: "workspace-2", path: "/tmp/proj-auto/workspace-2" }, + ], + trusted: true, + }, + ], + ]), + })), + }; + + const mockAIService = { + ...createStreamLifecycleMocks(), + isStreaming: mock(() => false), + // eslint-disable-next-line @typescript-eslint/no-empty-function + on: mock(() => {}), + // eslint-disable-next-line @typescript-eslint/no-empty-function + off: mock(() => {}), + } as unknown as AIService; + const createWorkspaceMock = mock(() => + Promise.resolve({ success: true as const, workspacePath }) + ); + + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + createWorkspace: createWorkspaceMock, + } as unknown as ReturnType); + + try { + const workspaceService = new WorkspaceService( + mockConfig as Config, + historyService, + mockAIService, + new ContextManagementService({ + config: mockConfig as Config, + historyService, + aiService: mockAIService, + }), + mockInitStateManager as InitStateManager, + mockExtensionMetadataService as ExtensionMetadataService, + mockBackgroundProcessManager as BackgroundProcessManager, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { getEffectiveSecrets: mock(() => []) } as unknown as SecretsStore + ); + + const removingWorkspaces = ( + workspaceService as unknown as { removingWorkspaces: Set } + ).removingWorkspaces; + // Skip the background init path so the test stays focused on auto-naming/persistence. + removingWorkspaces.add(workspaceId); + + // Record the persisted consent while registration-time sanitization runs. + const consentDuringSanitize: unknown[] = []; + spyOn( + workspaceService as unknown as { + sanitizeStalePluginOverridesForNewWorkspace: ( + workspaceId: string, + workspacePath: string + ) => Promise; + }, + "sanitizeStalePluginOverridesForNewWorkspace" + ).mockImplementation((id: string) => { + consentDuringSanitize.push( + configState.projects.get(projectPath)?.workspaces.find((entry) => entry.id === id) + ?.unrelatedWorkspaceConsent + ); + return Promise.resolve(undefined); + }); + + const result = await workspaceService.create( + projectPath, + // No branchName — backend should auto-generate workspace-3. + undefined, + undefined, + undefined, + { type: "local" }, + undefined, + // pendingAutoTitle: true mirrors the /fork-with-message flow. + true, + undefined, + { deferUnrelatedWorkspaceConsent: true } + ); + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + // Backend picked the next "workspace-N" slot and threaded it through to + // both the runtime call and the persisted config entry. + expect(createWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + branchName: "workspace-3", + directoryName: "workspace-3", + }) + ); + + const persisted = configState.projects.get(projectPath)?.workspaces ?? []; + const newEntry = persisted.find((entry) => entry.id === workspaceId); + expect(newEntry?.name).toBe("workspace-3"); + expect(newEntry?.pendingAutoTitle).toBe(true); + // WorkspaceTurnManager grants it later, once its handle reservation exists; until then + // the target is neither persisted nor announced as consented... + expect(newEntry?.unrelatedWorkspaceConsent).toBeUndefined(); + expect(result.data.metadata.unrelatedWorkspaceConsent).toBeUndefined(); + // ...but the deferred default is pending, so that later grant applies. + expect( + ( + workspaceService as unknown as { pendingDefaultUnrelatedConsent: Set } + ).pendingDefaultUnrelatedConsent.has(workspaceId) + ).toBe(true); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + test("remove() aborts init and clears state before teardown", async () => { const workspaceId = "ws-remove-aborts"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 329c16c9045..a2851b5468c 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2963,6 +2963,13 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { */ private readonly pendingPluginSanitizations = new Set(); + /** + * Workspaces created with `deferUnrelatedWorkspaceConsent` whose default grant has not run + * yet. An explicit consent toggle removes the entry, so the deferred grant (checked inside the + * serialized config edit) can never reverse a choice the user already made. + */ + private readonly pendingDefaultUnrelatedConsent = new Set(); + /** * Serializes persist + sanitize of a new host-local registration across * PROCESSES sharing this config root. pendingPluginSanitizations only @@ -5738,6 +5745,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { createResult!.workspacePath ); completeMetadata = { ...completeMetadata, unrelatedWorkspaceConsent }; + } else { + // Marked before the workspace is announced, so any toggle the user makes after it + // appears cancels the deferred default (see pendingDefaultUnrelatedConsent). + this.pendingDefaultUnrelatedConsent.add(workspaceId); } } finally { await releaseRegistrationLock?.(); @@ -7500,6 +7511,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } const { normalizedWorkspaceId, projectPath, workspacePath } = resolved.data; + // An explicit choice (either value) supersedes a still-pending creation default; cleared + // before this edit is queued, so a deferred grant queued later re-checks and skips. + this.pendingDefaultUnrelatedConsent.delete(normalizedWorkspaceId); // Mutate inside the serialized editConfig transform against the FRESH entry (see // findFreshWorkspaceEntry): a stale snapshot write could resurrect a removed workspace. let outcome: Result = Err("Workspace not found"); @@ -7555,7 +7569,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private async grantCreationUnrelatedWorkspaceConsent( projectPath: string, workspaceId: string, - workspacePath: string + workspacePath: string, + /** Re-checked inside the serialized edit; false skips the grant. */ + shouldGrant: () => boolean = () => true ): Promise { let granted: string | undefined; try { @@ -7565,7 +7581,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { workspaceId, workspacePath, }); - if (!entry) { + if (!entry || !shouldGrant()) { return freshConfig; } granted = @@ -7595,17 +7611,22 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * resulting metadata so the settings switch reflects the grant. */ async grantDefaultUnrelatedWorkspaceConsent(workspaceId: string): Promise { - const found = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId); - if (found == null) { - return; - } - const granted = await this.grantCreationUnrelatedWorkspaceConsent( - found.projectPath, - workspaceId, - found.workspace.path - ); - if (granted != null) { - await this.emitCurrentWorkspaceMetadata(workspaceId); + try { + const found = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId); + if (found == null || !this.pendingDefaultUnrelatedConsent.has(workspaceId)) { + return; + } + const granted = await this.grantCreationUnrelatedWorkspaceConsent( + found.projectPath, + workspaceId, + found.workspace.path, + () => this.pendingDefaultUnrelatedConsent.has(workspaceId) + ); + if (granted != null) { + await this.emitCurrentWorkspaceMetadata(workspaceId); + } + } finally { + this.pendingDefaultUnrelatedConsent.delete(workspaceId); } } diff --git a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts index 49172076984..dc78d054649 100644 --- a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts +++ b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts @@ -305,6 +305,12 @@ describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { await harness.cleanup(); }); + /** What create() records when it defers the default grant to WorkspaceTurnManager. */ + const markDeferredDefault = (workspaceId = WORKSPACE_ID) => + ( + harness.service as unknown as { pendingDefaultUnrelatedConsent: Set } + ).pendingDefaultUnrelatedConsent.add(workspaceId); + test("persists a generation for that workspace only and publishes it", async () => { const published: Array<{ workspaceId: string; @@ -316,6 +322,7 @@ describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { published.push(event) ); + markDeferredDefault(); await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); const generation = harness.persistedConsent(); @@ -326,6 +333,41 @@ describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { expect(published[0].workspaceId).toBe(WORKSPACE_ID); expect(published[0].metadata?.unrelatedWorkspaceConsent).toBe(generation as string); }); + + test("an explicit toggle while the default is pending wins over the deferred grant", async () => { + markDeferredDefault(); + // The user turns it on and back off after the workspace appeared, before the grant runs. + expect((await harness.service.setUnrelatedWorkspaceConsent(WORKSPACE_ID, true)).success).toBe( + true + ); + expect((await harness.service.setUnrelatedWorkspaceConsent(WORKSPACE_ID, false)).success).toBe( + true + ); + const published: unknown[] = []; + harness.service.on("metadata", (event: unknown) => published.push(event)); + + await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + + expect(harness.persistedConsent()).toBeUndefined(); + expect(published).toEqual([]); + }); + + test("grants nothing to a workspace whose creation did not defer the default", async () => { + // Existing workspaces must never be backfilled, even through the deferred-grant entry point. + await harness.service.grantDefaultUnrelatedWorkspaceConsent(OTHER_WORKSPACE_ID); + expect(harness.persistedConsent(OTHER_WORKSPACE_ID)).toBeUndefined(); + + // And the pending mark is one-shot: a second call after a grant does nothing new. + markDeferredDefault(); + await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + const generation = harness.persistedConsent(); + expect((await harness.service.setUnrelatedWorkspaceConsent(WORKSPACE_ID, false)).success).toBe( + true + ); + await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + expect(generation).toBeDefined(); + expect(harness.persistedConsent()).toBeUndefined(); + }); }); describe("getValidUnrelatedWorkspaceConsent", () => { From 7b88b06780d17c8a40ca8f5158bc129324a1371e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 15:44:01 +0000 Subject: [PATCH 7/9] fix: deferred consent publication is best-effort so it cannot wedge a delegated turn --- src/node/services/workspaceService.ts | 9 +++++++++ ...kspaceService.unrelatedWorkspaceConsent.test.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index a2851b5468c..2d8b1a1b7ed 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -7625,6 +7625,15 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (granted != null) { await this.emitCurrentWorkspaceMetadata(workspaceId); } + } catch (error) { + // Never throws: WorkspaceTurnManager awaits this after persisting and reserving its + // handle, so an exception would skip the send and every settlement path and leave a + // stuck "running" handle. The grant itself is durable; publication is best-effort and + // the next metadata refresh shows it. + log.warn("Failed to publish default unrelated-workspace consent", { + workspaceId, + error: getErrorMessage(error), + }); } finally { this.pendingDefaultUnrelatedConsent.delete(workspaceId); } diff --git a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts index dc78d054649..b6420ffd869 100644 --- a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts +++ b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts @@ -352,6 +352,20 @@ describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { expect(published).toEqual([]); }); + test("a failing metadata publication does not throw out of the deferred grant", async () => { + markDeferredDefault(); + // A downstream metadata consumer throwing makes the publication reject. + harness.service.on("metadata", () => { + throw new Error("metadata consumer exploded"); + }); + + // Resolves (WorkspaceTurnManager must still reach its send/settlement paths)... + await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + // ...and the grant itself stays durable. + const generation = harness.persistedConsent(); + expect(getValidUnrelatedWorkspaceConsent(generation)).toBe(generation as string); + }); + test("grants nothing to a workspace whose creation did not defer the default", async () => { // Existing workspaces must never be backfilled, even through the deferred-grant entry point. await harness.service.grantDefaultUnrelatedWorkspaceConsent(OTHER_WORKSPACE_ID); From a835a9bc4519e9f809bedfbbc37ad4ff57772cc5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 19:16:32 +0000 Subject: [PATCH 8/9] refactor: scope default consent to user-created workspaces; grant deferred checkouts after sanitization; restore post-write re-read --- docs/hooks/tools.mdx | 10 +- .../WorkspaceUnrelatedMessagingModal.tsx | 4 +- src/common/orpc/schemas/workspace.ts | 5 +- src/common/utils/tools/toolDefinitions.ts | 6 +- .../builtInSkillContent.generated.ts | 10 +- .../services/taskWorkspaceSeam.testUtils.ts | 1 - src/node/services/taskWorkspaceSeam.ts | 4 +- src/node/services/tools/task_list.ts | 2 +- src/node/services/workspaceService.test.ts | 43 +++-- src/node/services/workspaceService.ts | 91 ++++++---- ...eService.unrelatedWorkspaceConsent.test.ts | 169 +++++++++++++----- .../services/workspaceTurnManager.test.ts | 31 +--- src/node/services/workspaceTurnManager.ts | 18 +- 13 files changed, 228 insertions(+), 166 deletions(-) diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 0a09f2fbcf6..ee09a4b1e6c 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -808,11 +808,11 @@ If a value is too large for the environment, it may be omitted (not set). Xum al
task_send_message (3) -| Env var | JSON path | Type | Description | -| ------------------------------------ | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. | -| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). | -| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces are opted in by default; others enable it in their workspace settings). | +| Env var | JSON path | Type | Description | +| ------------------------------------ | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. | +| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). | +| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces other than task(kind:"workspace") targets are opted in by default; others enable it in their workspace settings). |
diff --git a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx index b2bb9953aed..f6bb18ac4c9 100644 --- a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx +++ b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingModal.tsx @@ -82,8 +82,8 @@ export function WorkspaceUnrelatedMessagingModal(props: WorkspaceUnrelatedMessag
Applies to agents in other local chats in this Xum instance, outside this - chat's task tree. On by default for new top-level chats; same-tree sub-agents - are unaffected. + chat's task tree. On by default for new chats you create; same-tree + sub-agents are unaffected.
diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 47045ab0fdd..03987ac2a24 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -122,11 +122,12 @@ export const WorkflowTaskMetadataSchema = z.object({ * The value is an opaque revocation GENERATION, not a bearer credential: the sender never supplies * it; the backend compares the generation captured at admission with the current one so an * off→on flip cannot revive work queued under the previous consent. Absent means off. New root - * workspaces are created with a fresh generation (on by default); the app's settings surface writes it — same-UID processes with config access can too, so it is an + * workspaces (other than task-delegated targets) get a fresh generation once their creation setup + * is complete (on by default); the app's settings surface writes it — same-UID processes with config access can too, so it is an * application-level opt-in, not an isolation boundary. */ export const UNRELATED_WORKSPACE_CONSENT_DESCRIPTION = - "Opaque consent generation allowing unrelated local workspaces (other task trees in this Xum instance) to discover this workspace and send it untrusted agent messages. New root workspaces start with one; absent means off; each off→on transition mints a new value, and an already-on workspace keeps its value. Never a bearer credential."; + "Opaque consent generation allowing unrelated local workspaces (other task trees in this Xum instance) to discover this workspace and send it untrusted agent messages. New root workspaces (other than task-delegated targets) start with one; absent means off; each off→on transition mints a new value, and an already-on workspace keeps its value. Never a bearer credential."; /** * Fail-closed reader for the persisted consent generation. Config entries are loaded without diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index ed6f8238a60..15f7dc80646 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -1073,7 +1073,7 @@ export const TaskSendMessageToolArgsSchema = z .string() .min(1) .describe( - 'Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces are opted in by default; others enable it in their workspace settings).' + 'Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces other than task(kind:"workspace") targets are opted in by default; others enable it in their workspace settings).' ), message: z .string() @@ -3154,7 +3154,7 @@ export const TOOL_DEFINITIONS = { 'Send a plain-text message to another agent workspace in this Xum instance: a descendant sub-agent, a sibling/cousin, an ancestor (including the root workspace), or an unrelated workspace outside your task tree. The relationship is computed server-side — you can never claim parent authority you do not have. Same-tree peers are discoverable with task_list scope:"tree"; an unrelated workspace ID you already know (an envelope "from" reply address, or an ID the user provided) is addressable only when that recipient has opted in. ' + "Descendant targets receive trusted guidance: queued/running work is interrupted or queued at the requested boundary, and an inactive child is reawakened in the same persistent workspace under a fresh internal execution. The stable sub-agent task ID and durable role title remain unchanged, and the child's checkout is not refreshed automatically. Prefer reawakening an inactive child over spawning a replacement when its prior context or expertise is relevant. For repository-dependent work, reuse it only when the retained snapshot is appropriate or tell the child to verify and synchronize its checkout before acting; otherwise spawn a new child. If the new assignment changes the child's reusable responsibility, call task_retitle as well; do not retitle it for ordinary one-off assignments. " + "Sibling, ancestor, and unrelated targets receive your message wrapped in an untrusted envelope carrying your ID (the reply address) and relationship; sub-agent targets must have a live turn/session (peers cannot reawaken inactive targets or edit queued launch prompts — that stays parent-only), while idle root workspaces wake. Never ask a peer to do something your own constraints forbid; route such work back to the user. Peer sends are throttled (rate limits, duplicate suppression, queue and consecutive-wake caps) and refused for workflow-owned or best-of endpoints. " + - "Unrelated messaging requires the actual recipient's consent: newly created root workspaces are opted in by default, older workspaces and sub-agents only after enabling it in their workspace settings, and any workspace can turn it off; knowing its ID or its parent's consent does not grant access. Revocation cancels input not yet admitted, even after re-enabling; an already admitted turn may finish. Unrelated-message turns need user action to resume after an app restart. This tool cannot grant consent. Both endpoints must use local or worktree runtimes; SSH (including Coder), Docker, devcontainer, and unresolved runtimes are refused. Same-tree messaging is unchanged. Unrelated targets default to turn-end dispatch and keep their own agent, model, and thinking settings — your settings are never applied or persisted there. Messaging grants no additional control: your existing rights over task-tree descendants and over workspace-turn handles you already own remain exactly as before, and no other rights are added. An unrelated root that is inside a delegated workspace turn is temporarily unavailable and returns refused with a retry-after reason; retry once that turn finishes. " + + "Unrelated messaging requires the actual recipient's consent: root workspaces created after this default shipped are opted in (except task(kind:\"workspace\") targets, for now), while older workspaces, delegated targets and sub-agents are opted in only after enabling it in their workspace settings, and any workspace can turn it off; knowing its ID or its parent's consent does not grant access. Revocation cancels input not yet admitted, even after re-enabling; an already admitted turn may finish. Unrelated-message turns need user action to resume after an app restart. This tool cannot grant consent. Both endpoints must use local or worktree runtimes; SSH (including Coder), Docker, devcontainer, and unresolved runtimes are refused. Same-tree messaging is unchanged. Unrelated targets default to turn-end dispatch and keep their own agent, model, and thinking settings — your settings are never applied or persisted there. Messaging grants no additional control: your existing rights over task-tree descendants and over workspace-turn handles you already own remain exactly as before, and no other rights are added. An unrelated root that is inside a delegated workspace turn is temporarily unavailable and returns refused with a retry-after reason; retry once that turn finishes. " + "This tool does not target bash tasks, workflow runs, workspace-turn handles, or workspaces in other Xum instances.", schema: TaskSendMessageToolArgsSchema, }, @@ -3212,7 +3212,7 @@ export const TOOL_DEFINITIONS = { "When recovering an uncertain workflow_run, omit statuses first or include pending/running/backgrounded as well as interrupted/failed/completed; terminal-only filters can hide unfinished workflow runs. Pending runs may need workflow_resume because no runner may be active yet. " + "Workflow rows may include compact `workflowProgress` so callers can see the latest phase before deciding whether to await, resume, or leave the run alone. " + 'Pass scope:"tree" to list every agent workspace in this task tree instead — ancestors, siblings/cousins, descendants, and the root workspace row (status "workspace") — each tagged with its relationship to you. Tree rows are addressable via task_send_message except your own "self" row, best-of candidate rows (`bestOf` metadata, refused to keep candidates independent), and non-descendant rows in terminal states (peers cannot reactivate an inactive task — only its parent can); the root row is included by default and filtered like any other row when explicit statuses are passed. ' + - 'Pass scope:"instance" from a local/worktree workspace for the on-demand address book of this Xum instance: eligible local/worktree root workspaces across projects (status "workspace", never another tree\'s sub-agents), tagged self, ancestor, or unrelated, ordered newest first by createdAt. Unrelated roots must have consented (newly created roots are opted in by default; others enable it in their workspace settings); absent or revoked consent hides them before searching, counting, and paging. Consent does not hide your own task-tree root. Narrow with `query` (ID, title, name, project path), page with `limit`/`offset`, and continue from `nextOffset` when it is returned; `activity` (busy/idle) is a snapshot taken at listing time. Rows are addressable via task_send_message — unrelated targets receive your text as an untrusted agent message, queued to turn-end while they are busy, under their own agent/model settings; discovery grants no additional control, and existing ownership rights remain unchanged. ' + + 'Pass scope:"instance" from a local/worktree workspace for the on-demand address book of this Xum instance: eligible local/worktree root workspaces across projects (status "workspace", never another tree\'s sub-agents), tagged self, ancestor, or unrelated, ordered newest first by createdAt. Unrelated roots must have consented (newly created roots other than task(kind:"workspace") targets are opted in by default; others enable it in their workspace settings); absent or revoked consent hides them before searching, counting, and paging. Consent does not hide your own task-tree root. Narrow with `query` (ID, title, name, project path), page with `limit`/`offset`, and continue from `nextOffset` when it is returned; `activity` (busy/idle) is a snapshot taken at listing time. Rows are addressable via task_send_message — unrelated targets receive your text as an untrusted agent message, queued to turn-end while they are busy, under their own agent/model settings; discovery grants no additional control, and existing ownership rights remain unchanged. ' + "The legacy includeArchived option only affects archived workspace-turn and bash records; sub-agents remain one inactive/active task identity. " + "This is a discovery tool, NOT a waiting mechanism. If the current request actually depends on a task's output, call task_await with the specific task IDs you need; do not await all active tasks just because they appear here.", schema: TaskListToolArgsSchema, diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index c975c078398..b03a00914fc 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6975,11 +6975,11 @@ export const BUILTIN_SKILL_FILES: Record> = { "
", "task_send_message (3)", "", - "| Env var | JSON path | Type | Description |", - "| ------------------------------------ | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |", - "| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. |", - '| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). |', - '| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces are opted in by default; others enable it in their workspace settings). |', + "| Env var | JSON path | Type | Description |", + "| ------------------------------------ | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |", + "| `XUM_TOOL_INPUT_MESSAGE` | `message` | string | Plain-text message to deliver to the target. Sibling/upward sends are capped at 16384 characters and draw from shared per-pair/per-target session budgets; descendant guidance is uncapped. |", + '| `XUM_TOOL_INPUT_QUEUE_DISPATCH_MODE` | `queue_dispatch_mode` | enum | When the target is busy, dispatch at "tool-end" after its next tool call or at "turn-end" after its current turn. Defaults to "tool-end" for descendant and sibling targets and "turn-end" for ancestor and unrelated targets (often human-driven; do not cut into their active turn). |', + '| `XUM_TOOL_INPUT_TASK_ID` | `task_id` | string | Target workspace ID: a descendant sub-agent task ID returned by task, a same-tree row from task_list scope:"tree" (peer, ancestor, or root), an opted-in root workspace row from task_list scope:"instance", or any other workspace ID in this Xum instance that you already know — an envelope "from" reply address or an ID the user provided. Unrelated (cross-tree) targets may be root workspaces or live sub-agents of other trees, but both sender and target must use local or worktree runtimes and the actual recipient must have consented (newly created root workspaces other than task(kind:"workspace") targets are opted in by default; others enable it in their workspace settings). |', "", "
", "", diff --git a/src/node/services/taskWorkspaceSeam.testUtils.ts b/src/node/services/taskWorkspaceSeam.testUtils.ts index ada3b1dcfac..af05d87b48a 100644 --- a/src/node/services/taskWorkspaceSeam.testUtils.ts +++ b/src/node/services/taskWorkspaceSeam.testUtils.ts @@ -60,7 +60,6 @@ export function makeWorkspaceHostFake(overrides: Partial = {}): W remove: () => Promise.resolve(Ok(undefined)), removeWhileTaskTreeLocked: () => Promise.resolve(Ok(undefined)), create: () => Promise.resolve(Err("workspaceHost.create not mocked")), - grantDefaultUnrelatedWorkspaceConsent: () => Promise.resolve(), // Task-create tests exercise launch flow, not plugin-override sanitization. sanitizeMaterializedTaskWorkspace: () => Promise.resolve(undefined), discardExtensionMetadataEntry: () => Promise.resolve(), diff --git a/src/node/services/taskWorkspaceSeam.ts b/src/node/services/taskWorkspaceSeam.ts index 4dc37b0da17..0b1c1fdd3a1 100644 --- a/src/node/services/taskWorkspaceSeam.ts +++ b/src/node/services/taskWorkspaceSeam.ts @@ -664,10 +664,8 @@ export interface WorkspaceProvisioningHost { subProjectPath?: string, pendingAutoTitle?: boolean, tags?: Record, - options?: { awaitMaterialization?: boolean; deferUnrelatedWorkspaceConsent?: boolean } + options?: { awaitMaterialization?: boolean; skipDefaultUnrelatedWorkspaceConsent?: boolean } ): Promise>; - /** Default unrelated-messaging consent for a target created with the grant deferred. */ - grantDefaultUnrelatedWorkspaceConsent(workspaceId: string): Promise; sanitizeMaterializedTaskWorkspace( workspaceId: string, workspacePath: string, diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index 97b4d316bd6..c3dc58b2c98 100644 --- a/src/node/services/tools/task_list.ts +++ b/src/node/services/tools/task_list.ts @@ -86,7 +86,7 @@ const TREE_SCOPE_RESTRICTED_NOTE = "This workspace cannot send or receive peer messages (best-of candidates stay independent; workflow-owned tasks communicate through the workflow journal), so only self/descendant rows are listed; descendants remain addressable via task_send_message guidance."; const INSTANCE_SCOPE_NOTE = - "Rows are local/worktree root workspaces in this Xum instance and an availability snapshot. Unrelated roots must have consented (newly created roots are opted in by default; others enable it in their workspace settings); absent or revoked consent hides them, including from searches and counts. Non-local or unresolved runtimes, archived, user-stopped, stopping, and delegated-turn roots are omitted, and a listed target can still refuse if its state changes (including consent, runtime, or a delegated turn starting on it). " + + 'Rows are local/worktree root workspaces in this Xum instance and an availability snapshot. Unrelated roots must have consented (newly created roots other than task(kind:"workspace") targets are opted in by default; others enable it in their workspace settings); absent or revoked consent hides them, including from searches and counts. Non-local or unresolved runtimes, archived, user-stopped, stopping, and delegated-turn roots are omitted, and a listed target can still refuse if its state changes (including consent, runtime, or a delegated turn starting on it). ' + 'Message them with task_send_message — "unrelated" rows receive your text as an untrusted agent message, queued to turn-end while they are busy, under their own agent/model settings; discovery grants no additional control. Your own "self" row is not addressable.'; // The tree note promises self/descendant rows, which would be false here: a restricted caller diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index f163c9585ed..b90bee78fbb 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -21539,7 +21539,22 @@ describe("WorkspaceService init cancellation", () => { }), }; - const configState: ProjectsConfig = { projects: new Map() }; + // Two pre-existing workspaces — auto-naming should skip past them. loadConfigOrDefault + // returns the same state editConfig mutates, so post-write re-reads see real writes. + const configState: ProjectsConfig = { + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { id: "x", name: "workspace-1", path: "/tmp/proj-auto/workspace-1" }, + { id: "y", name: "workspace-2", path: "/tmp/proj-auto/workspace-2" }, + ], + trusted: true, + }, + ], + ]), + }; const mockMetadata: FrontendWorkspaceMetadata = { id: workspaceId, @@ -21563,21 +21578,7 @@ describe("WorkspaceService init cancellation", () => { getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), sessionsDir: "/tmp/test/sessions", findWorkspace: mock(() => null), - // Two pre-existing workspaces — auto-naming should skip past them. - loadConfigOrDefault: mock(() => ({ - projects: new Map([ - [ - projectPath, - { - workspaces: [ - { id: "x", name: "workspace-1", path: "/tmp/proj-auto/workspace-1" }, - { id: "y", name: "workspace-2", path: "/tmp/proj-auto/workspace-2" }, - ], - trusted: true, - }, - ], - ]), - })), + loadConfigOrDefault: mock(() => configState), }; const mockAIService = { @@ -21687,7 +21688,7 @@ describe("WorkspaceService init cancellation", () => { } }); - test("create() with deferUnrelatedWorkspaceConsent leaves consent off and marks the default pending", async () => { + test("create() with skipDefaultUnrelatedWorkspaceConsent leaves the workspace opted out", async () => { // /new mirrors /fork's seamless flow: callers no longer have to invent a // workspace name. The backend should derive the next "workspace-N" slot // and persist `pendingAutoTitle` so the first message can title the workspace. @@ -21828,7 +21829,7 @@ describe("WorkspaceService init cancellation", () => { // pendingAutoTitle: true mirrors the /fork-with-message flow. true, undefined, - { deferUnrelatedWorkspaceConsent: true } + { skipDefaultUnrelatedWorkspaceConsent: true } ); expect(result.success).toBe(true); @@ -21849,16 +21850,14 @@ describe("WorkspaceService init cancellation", () => { const newEntry = persisted.find((entry) => entry.id === workspaceId); expect(newEntry?.name).toBe("workspace-3"); expect(newEntry?.pendingAutoTitle).toBe(true); - // WorkspaceTurnManager grants it later, once its handle reservation exists; until then - // the target is neither persisted nor announced as consented... + // Delegated targets are not opted in (yet): nothing persisted, announced or pending. expect(newEntry?.unrelatedWorkspaceConsent).toBeUndefined(); expect(result.data.metadata.unrelatedWorkspaceConsent).toBeUndefined(); - // ...but the deferred default is pending, so that later grant applies. expect( ( workspaceService as unknown as { pendingDefaultUnrelatedConsent: Set } ).pendingDefaultUnrelatedConsent.has(workspaceId) - ).toBe(true); + ).toBe(false); } finally { createRuntimeSpy.mockRestore(); } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2d8b1a1b7ed..1636717c7f4 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1882,10 +1882,12 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick /** * Mints a fresh unrelated-messaging consent generation (see setUnrelatedWorkspaceConsent). * New root workspaces (create, scratch, multi-project, fork) are opted in by default so an agent - * in another task tree can reach them without a manual toggle. create grants it after - * registration-time plugin sanitization, fork after all of its setup, and delegated targets after - * WorkspaceTurnManager reserves their handle (grantCreationUnrelatedWorkspaceConsent); scratch and - * multi-project have no such steps and persist it with the entry. Pre-existing workspaces are + * in another task tree can reach them without a manual toggle. Consent is granted only once the + * workspace's creation setup is complete (grantCreationUnrelatedWorkspaceConsent): create after + * registration-time plugin sanitization or, for a deferred checkout, after that checkout's own + * sanitization; fork after all of its setup; scratch and multi-project have no such steps and + * persist it with the entry. Delegated task(kind:"workspace") targets are not opted in yet (they + * skip the default; tracked as a follow-up). Pre-existing workspaces are * deliberately not backfilled: an absent value means both "never enabled" and "turned off", so * a backfill would silently undo explicit opt-outs. Sub-agent children are created by * TaskService and stay off; their parent owns them. @@ -2964,9 +2966,11 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly pendingPluginSanitizations = new Set(); /** - * Workspaces created with `deferUnrelatedWorkspaceConsent` whose default grant has not run - * yet. An explicit consent toggle removes the entry, so the deferred grant (checked inside the - * serialized config edit) can never reverse a choice the user already made. + * Deferred-checkout creations whose default consent waits for the checkout's sanitization + * (materializeDeferredCheckout). The workspace is already announced then, so an explicit + * consent toggle removes the entry and the grant (re-checked inside the serialized config + * edit) can never reverse a choice the user already made. Process-local: a toggle handled by + * another backend sharing this root cannot cancel it (tracked with #4446). */ private readonly pendingDefaultUnrelatedConsent = new Set(); @@ -3361,6 +3365,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { initParams.initLogger.logComplete(-1); return; } + // Checkout populated and sanitized: only now may other task trees discover and message + // this workspace. Granting at registration would rely on waitForInit, which a second + // backend sharing this root does not observe. + await this.grantPendingDefaultUnrelatedWorkspaceConsent(workspaceId); await runBackgroundInit(runtime, initParams, workspaceId, log); } @@ -5397,12 +5405,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { */ awaitMaterialization?: boolean; /** - * Skip the default unrelated-messaging consent grant. WorkspaceTurnManager sets this - * for delegated targets and grants via grantDefaultUnrelatedWorkspaceConsent once its - * handle reservation exists, so no other task tree can wake the target before the - * delegated turn owns it. + * Do not opt this workspace in to unrelated messaging. WorkspaceTurnManager sets it for + * delegated targets until their default gets its own finalization design (follow-up). */ - deferUnrelatedWorkspaceConsent?: boolean; + skipDefaultUnrelatedWorkspaceConsent?: boolean; } ): Promise> { if (tags != null) { @@ -5736,19 +5742,22 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); } } - // Registration is complete (sanitized when required) and nothing has been announced - // yet: only now may other task trees discover and message this workspace. - if (options?.deferUnrelatedWorkspaceConsent !== true) { + if (options?.skipDefaultUnrelatedWorkspaceConsent === true) { + // Delegated target: stays off (see the option). + } else if (pendingMaterialization !== undefined) { + // Deferred checkout: its files (and their sanitization) arrive after the announcement, + // so the grant waits for materializeDeferredCheckout. Marked before announcing, so a + // toggle the user makes once the workspace appears cancels it. + this.pendingDefaultUnrelatedConsent.add(workspaceId); + } else { + // Registration is complete (sanitized when required) and nothing has been announced + // yet: only now may other task trees discover and message this workspace. const unrelatedWorkspaceConsent = await this.grantCreationUnrelatedWorkspaceConsent( owningProjectPath, workspaceId, createResult!.workspacePath ); completeMetadata = { ...completeMetadata, unrelatedWorkspaceConsent }; - } else { - // Marked before the workspace is announced, so any toggle the user makes after it - // appears cancels the deferred default (see pendingDefaultUnrelatedConsent). - this.pendingDefaultUnrelatedConsent.add(workspaceId); } } finally { await releaseRegistrationLock?.(); @@ -5792,7 +5801,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { initParams, pending: pendingMaterialization, initAbortController, - }) + // Removal, failed checkout or failed sanitization: the default never applies. + }).finally(() => this.pendingDefaultUnrelatedConsent.delete(workspaceId)) : runBackgroundInit(runtime, initParams, workspaceId, log) ); } else { @@ -7564,7 +7574,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * consent makes the entry discoverable (task_list scope:"instance" reads config directly) and * wakeable by other task trees, and an agent request during the sanitization window would * activate the stale plugin enable that sanitization exists to prune. Fails closed: if the - * edit throws the workspace simply stays off. Returns the granted generation, if any. + * edit throws or does not persist, the workspace simply stays off. Returns the persisted + * generation, if any. */ private async grantCreationUnrelatedWorkspaceConsent( projectPath: string, @@ -7597,20 +7608,31 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }); return undefined; } - // No post-write re-read: a swallowed save failure (Config.saveConfig logs and continues) - // leaves the edited object in the in-process config snapshot, so the settings switch, - // task_list discovery and send admission all read the same value; only a restart differs. - // That behavior is shared by every config edit, including setUnrelatedWorkspaceConsent. - return granted; + if (granted == null) { + return undefined; + } + // Config.saveConfig logs and swallows write failures, and editConfig's transform ran on an + // uncached read, so a failed save leaves loadConfigOrDefault() re-reading the unchanged + // file. Report only what discovery and admission will actually read (see #4444). + const persisted = getValidUnrelatedWorkspaceConsent( + findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId)?.workspace + .unrelatedWorkspaceConsent + ); + if (persisted !== granted) { + log.warn("Default unrelated-workspace consent did not persist; leaving it off", { + workspaceId, + }); + return undefined; + } + return persisted; } /** - * Deferred default consent for a workspace created with `deferUnrelatedWorkspaceConsent`. - * Called by WorkspaceTurnManager once the delegated turn's handle reservation is installed, - * so unrelated senders see a delegated root (refused) rather than an idle one. Publishes the - * resulting metadata so the settings switch reflects the grant. + * Default consent for a deferred-checkout creation, once materializeDeferredCheckout has + * populated and sanitized it. Applies only while the creation is still pending (an explicit + * toggle cancels it), and publishes the metadata since the workspace is already announced. */ - async grantDefaultUnrelatedWorkspaceConsent(workspaceId: string): Promise { + private async grantPendingDefaultUnrelatedWorkspaceConsent(workspaceId: string): Promise { try { const found = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId); if (found == null || !this.pendingDefaultUnrelatedConsent.has(workspaceId)) { @@ -7626,10 +7648,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { await this.emitCurrentWorkspaceMetadata(workspaceId); } } catch (error) { - // Never throws: WorkspaceTurnManager awaits this after persisting and reserving its - // handle, so an exception would skip the send and every settlement path and leave a - // stuck "running" handle. The grant itself is durable; publication is best-effort and - // the next metadata refresh shows it. + // Never throws: it runs inside the deferred checkout's init settlement, which must go on + // to run the init hook. The grant itself is durable; publication is best-effort and the + // next metadata refresh shows it. log.warn("Failed to publish default unrelated-workspace consent", { workspaceId, error: getErrorMessage(error), diff --git a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts index b6420ffd869..5159cd792fc 100644 --- a/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts +++ b/src/node/services/workspaceService.unrelatedWorkspaceConsent.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -6,6 +6,7 @@ import * as path from "node:path"; import type { Workspace } from "@/common/types/project"; import { getValidUnrelatedWorkspaceConsent } from "@/common/orpc/schemas/workspace"; import type { Config } from "@/node/config"; +import * as runtimeFactory from "@/node/runtime/runtimeFactory"; import type { AIService } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { ExtensionMetadataService } from "./ExtensionMetadataService"; @@ -293,7 +294,7 @@ describe("WorkspaceService.setUnrelatedWorkspaceConsent", () => { }); }); -describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { +describe("WorkspaceService deferred-checkout default consent", () => { let harness: Awaited>; beforeEach(async () => { @@ -305,37 +306,94 @@ describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { await harness.cleanup(); }); - /** What create() records when it defers the default grant to WorkspaceTurnManager. */ - const markDeferredDefault = (workspaceId = WORKSPACE_ID) => - ( - harness.service as unknown as { pendingDefaultUnrelatedConsent: Set } - ).pendingDefaultUnrelatedConsent.add(workspaceId); - - test("persists a generation for that workspace only and publishes it", async () => { - const published: Array<{ - workspaceId: string; - metadata: { unrelatedWorkspaceConsent?: string } | null; - }> = []; - harness.service.on( - "metadata", - (event: { workspaceId: string; metadata: { unrelatedWorkspaceConsent?: string } | null }) => - published.push(event) - ); + interface ServiceInternals { + pendingDefaultUnrelatedConsent: Set; + grantPendingDefaultUnrelatedWorkspaceConsent: (workspaceId: string) => Promise; + sanitizeMaterializedTaskWorkspace: (...args: unknown[]) => Promise; + abortUnsanitizedCreation: (...args: unknown[]) => Promise; + materializeDeferredCheckout: (args: unknown) => Promise; + saveConfig: (config: unknown) => Promise; + grantCreationUnrelatedWorkspaceConsent: ( + projectPath: string, + workspaceId: string, + workspacePath: string + ) => Promise; + } + const internals = () => harness.service as unknown as ServiceInternals; + /** What create() records for a deferred checkout before announcing it. */ + const markPending = (workspaceId = WORKSPACE_ID) => + internals().pendingDefaultUnrelatedConsent.add(workspaceId); + const grantPending = (workspaceId = WORKSPACE_ID) => + internals().grantPendingDefaultUnrelatedWorkspaceConsent(workspaceId); + + /** Drives the real deferred-checkout settlement with a fake runtime. */ + async function runDeferredCheckout(options: { materializeError?: Error } = {}) { + const initLogger = { logStderr: mock(() => undefined), logComplete: mock(() => undefined) }; + await internals().materializeDeferredCheckout({ + workspaceId: WORKSPACE_ID, + runtime: { + materializeWorkspace: mock(() => + options.materializeError + ? Promise.reject(options.materializeError) + : Promise.resolve(undefined) + ), + }, + runtimeConfig: { type: "worktree" }, + workspaceName: "consent-ws", + initParams: { + projectPath: harness.projectPath, + workspacePath: harness.workspacePath, + initLogger, + trusted: true, + }, + pending: {}, + initAbortController: new AbortController(), + }); + } + + test("grants only after the checkout is sanitized, before the init hook runs", async () => { + markPending(); + const consentAtSanitize: unknown[] = []; + const consentAtInit: unknown[] = []; + spyOn(internals(), "sanitizeMaterializedTaskWorkspace").mockImplementation(() => { + consentAtSanitize.push(harness.persistedConsent()); + return Promise.resolve(undefined); + }); + spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => { + consentAtInit.push(harness.persistedConsent()); + return Promise.resolve(undefined); + }); + const published: Array<{ workspaceId: string }> = []; + harness.service.on("metadata", (event: { workspaceId: string }) => published.push(event)); - markDeferredDefault(); - await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + await runDeferredCheckout(); + // Not discoverable while stale plugin enables could still be unpruned... + expect(consentAtSanitize).toEqual([undefined]); + // ...granted (and published, since the workspace is already announced) before init. const generation = harness.persistedConsent(); - expect(typeof generation).toBe("string"); expect(getValidUnrelatedWorkspaceConsent(generation)).toBe(generation as string); + expect(consentAtInit).toEqual([generation]); + expect(published.map((event) => event.workspaceId)).toEqual([WORKSPACE_ID]); expect(harness.persistedConsent(OTHER_WORKSPACE_ID)).toBeUndefined(); - expect(published).toHaveLength(1); - expect(published[0].workspaceId).toBe(WORKSPACE_ID); - expect(published[0].metadata?.unrelatedWorkspaceConsent).toBe(generation as string); }); - test("an explicit toggle while the default is pending wins over the deferred grant", async () => { - markDeferredDefault(); + test.each([ + { label: "failed sanitization", sanitizeError: "stale enable", materializeError: undefined }, + { label: "failed checkout", sanitizeError: undefined, materializeError: new Error("clone") }, + ])("$label never grants", async ({ sanitizeError, materializeError }) => { + markPending(); + spyOn(internals(), "sanitizeMaterializedTaskWorkspace").mockResolvedValue(sanitizeError); + spyOn(internals(), "abortUnsanitizedCreation").mockResolvedValue(true); + spyOn(runtimeFactory, "runBackgroundInit").mockResolvedValue(undefined); + + await runDeferredCheckout({ materializeError }); + + expect(harness.persistedConsent()).toBeUndefined(); + }); + + test("an explicit toggle while the default is pending wins", async () => { + markPending(); // The user turns it on and back off after the workspace appeared, before the grant runs. expect((await harness.service.setUnrelatedWorkspaceConsent(WORKSPACE_ID, true)).success).toBe( true @@ -346,42 +404,55 @@ describe("WorkspaceService.grantDefaultUnrelatedWorkspaceConsent", () => { const published: unknown[] = []; harness.service.on("metadata", (event: unknown) => published.push(event)); - await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + await grantPending(); expect(harness.persistedConsent()).toBeUndefined(); expect(published).toEqual([]); }); - test("a failing metadata publication does not throw out of the deferred grant", async () => { - markDeferredDefault(); - // A downstream metadata consumer throwing makes the publication reject. + test("never grants a workspace that is not pending, and the mark is one-shot", async () => { + // Existing workspaces must never be backfilled through this path. + await grantPending(OTHER_WORKSPACE_ID); + expect(harness.persistedConsent(OTHER_WORKSPACE_ID)).toBeUndefined(); + + markPending(); + await grantPending(); + expect(harness.persistedConsent()).toBeDefined(); + expect((await harness.service.setUnrelatedWorkspaceConsent(WORKSPACE_ID, false)).success).toBe( + true + ); + await grantPending(); + expect(harness.persistedConsent()).toBeUndefined(); + }); + + test("does not report consent whose save was swallowed", async () => { + // Config.saveConfig logs and swallows write failures; model one reaching the real edit path. + spyOn(harness.config as unknown as ServiceInternals, "saveConfig").mockResolvedValue(undefined); + + // create() and fork() announce exactly what this returns, so it must be the persisted + // value (none), not the one the transform wrote in memory. + const reported = await internals().grantCreationUnrelatedWorkspaceConsent( + harness.projectPath, + WORKSPACE_ID, + harness.workspacePath + ); + + expect(reported).toBeUndefined(); + expect(harness.persistedConsent()).toBeUndefined(); + }); + + test("a failing metadata publication does not throw out of the grant", async () => { + markPending(); harness.service.on("metadata", () => { throw new Error("metadata consumer exploded"); }); - // Resolves (WorkspaceTurnManager must still reach its send/settlement paths)... - await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); + // Resolves (the deferred checkout must still go on to run its init hook)... + await grantPending(); // ...and the grant itself stays durable. const generation = harness.persistedConsent(); expect(getValidUnrelatedWorkspaceConsent(generation)).toBe(generation as string); }); - - test("grants nothing to a workspace whose creation did not defer the default", async () => { - // Existing workspaces must never be backfilled, even through the deferred-grant entry point. - await harness.service.grantDefaultUnrelatedWorkspaceConsent(OTHER_WORKSPACE_ID); - expect(harness.persistedConsent(OTHER_WORKSPACE_ID)).toBeUndefined(); - - // And the pending mark is one-shot: a second call after a grant does nothing new. - markDeferredDefault(); - await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); - const generation = harness.persistedConsent(); - expect((await harness.service.setUnrelatedWorkspaceConsent(WORKSPACE_ID, false)).success).toBe( - true - ); - await harness.service.grantDefaultUnrelatedWorkspaceConsent(WORKSPACE_ID); - expect(generation).toBeDefined(); - expect(harness.persistedConsent()).toBeUndefined(); - }); }); describe("getValidUnrelatedWorkspaceConsent", () => { diff --git a/src/node/services/workspaceTurnManager.test.ts b/src/node/services/workspaceTurnManager.test.ts index c48ffc5f9c0..c5f87d9c918 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -2071,29 +2071,16 @@ describe("WorkspaceTurnManager", () => { }); }); - test("createWorkspaceTurn defers default unrelated-messaging consent until its handle is reserved", async () => { + test("createWorkspaceTurn creates delegated targets without default unrelated-messaging consent", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["childworkspace", "turnhandle"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); - // What an unrelated sender's guards would see at the moment consent is granted. - let registrationAtGrant: unknown = "not granted"; - const managerRef: { - current?: ReturnType["taskService"]; - } = {}; - const grantDefaultUnrelatedWorkspaceConsent = mock((workspaceId: string) => { - registrationAtGrant = managerRef.current?.getLiveWorkspaceTurnRegistration(workspaceId); - return Promise.resolve(); - }); - const workspaceMocks = createWorkspaceServiceMocks({ - create: createWorkspace, - grantDefaultUnrelatedWorkspaceConsent, - }); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace }); const { taskService } = createWorkspaceTurnManagerHarness(config, { workspaceService: workspaceMocks.workspaceService, }); - managerRef.current = taskService; const result = await taskService.createWorkspaceTurn({ ownerWorkspaceId: parentId, @@ -2103,16 +2090,12 @@ describe("WorkspaceTurnManager", () => { }); expect(result.success).toBe(true); - // create() must not grant it: the target would be an idle, wakeable root before this turn - // owns it. + // Delegated targets need a default tied to this turn's lifecycle (follow-up to #4440); + // until then create() must not opt them in. const createCall = createWorkspace.mock.calls[0] as unknown[]; - expect(createCall[8]).toMatchObject({ deferUnrelatedWorkspaceConsent: true }); - // Granted exactly once, while the reservation already marks it as a delegated root. - expect(grantDefaultUnrelatedWorkspaceConsent).toHaveBeenCalledTimes(1); - expect(grantDefaultUnrelatedWorkspaceConsent.mock.calls[0]).toEqual(["childworkspace"]); - expect(registrationAtGrant).toMatchObject({ - handleId: "wst_childworkspace", - ownerWorkspaceId: parentId, + expect(createCall[8]).toMatchObject({ + awaitMaterialization: true, + skipDefaultUnrelatedWorkspaceConsent: true, }); }); diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index bd8dc63202d..f5f5023c074 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -1232,9 +1232,10 @@ export class WorkspaceTurnManager { false, tags, // The agentId validation below reads the target checkout under the task mutex, so - // a local worktree must be populated before create() resolves. Default consent is - // granted below, once this turn's handle reservation exists (see create()). - { awaitMaterialization: true, deferUnrelatedWorkspaceConsent: true } + // a local worktree must be populated before create() resolves. Delegated targets are + // not opted in to unrelated messaging yet: their default needs a finalization point + // tied to this turn's lifecycle (follow-up to #4440). + { awaitMaterialization: true, skipDefaultUnrelatedWorkspaceConsent: true } ); if (!createResult.success) { return Err(`Task.createWorkspaceTurn: workspace create failed (${createResult.error})`); @@ -1465,17 +1466,6 @@ export class WorkspaceTurnManager { } return Err("Task.createWorkspaceTurn: owner workspace was archived during turn creation"); } - if (createdWorkspace) { - // New root workspaces are opted in to unrelated messaging by default. Granted only now: - // the handle reservation above makes unrelated senders see a delegated root (refused, - // and hidden from task_list scope:"instance") instead of an idle one they could wake - // before this turn owns the workspace. - assert( - this.activeWorkspaceTurnHandleByWorkspaceId.get(targetWorkspaceId)?.handleId === handleId, - "createWorkspaceTurn: a created target's handle reservation must exist before consent" - ); - await this.workspaceService.grantDefaultUnrelatedWorkspaceConsent(targetWorkspaceId); - } if (agentValidationError != null) { // Deferred post-create validation failure: the record above keeps the created // workspace owner-owned (retryable via mode="existing"); settle the handle as a From 0674231f6fc21cc6352ace282e9a7542e8aa661c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 19:17:13 +0000 Subject: [PATCH 9/9] chore: reference #4453 for delegated-target default consent --- src/node/services/workspaceService.ts | 4 ++-- src/node/services/workspaceTurnManager.test.ts | 2 +- src/node/services/workspaceTurnManager.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 1636717c7f4..30b860a59f7 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1887,7 +1887,7 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick * registration-time plugin sanitization or, for a deferred checkout, after that checkout's own * sanitization; fork after all of its setup; scratch and multi-project have no such steps and * persist it with the entry. Delegated task(kind:"workspace") targets are not opted in yet (they - * skip the default; tracked as a follow-up). Pre-existing workspaces are + * skip the default; tracked in #4453). Pre-existing workspaces are * deliberately not backfilled: an absent value means both "never enabled" and "turned off", so * a backfill would silently undo explicit opt-outs. Sub-agent children are created by * TaskService and stay off; their parent owns them. @@ -5406,7 +5406,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { awaitMaterialization?: boolean; /** * Do not opt this workspace in to unrelated messaging. WorkspaceTurnManager sets it for - * delegated targets until their default gets its own finalization design (follow-up). + * delegated targets until their default gets its own finalization design (#4453). */ skipDefaultUnrelatedWorkspaceConsent?: boolean; } diff --git a/src/node/services/workspaceTurnManager.test.ts b/src/node/services/workspaceTurnManager.test.ts index c5f87d9c918..13c8f6d4d5c 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -2090,7 +2090,7 @@ describe("WorkspaceTurnManager", () => { }); expect(result.success).toBe(true); - // Delegated targets need a default tied to this turn's lifecycle (follow-up to #4440); + // Delegated targets need a default tied to this turn's lifecycle (#4453); // until then create() must not opt them in. const createCall = createWorkspace.mock.calls[0] as unknown[]; expect(createCall[8]).toMatchObject({ diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index f5f5023c074..e621323d2d8 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -1234,7 +1234,7 @@ export class WorkspaceTurnManager { // The agentId validation below reads the target checkout under the task mutex, so // a local worktree must be populated before create() resolves. Delegated targets are // not opted in to unrelated messaging yet: their default needs a finalization point - // tied to this turn's lifecycle (follow-up to #4440). + // tied to this turn's lifecycle (#4453). { awaitMaterialization: true, skipDefaultUnrelatedWorkspaceConsent: true } ); if (!createResult.success) {