diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 77234a125de..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 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 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 373c6aab410..f6bb18ac4c9 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 you create; 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..03987ac2a24 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -121,12 +121,13 @@ 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 (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. 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 52909302e0a..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 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 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 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: 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 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 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/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/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 409013b623f..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 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 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.ts b/src/node/services/taskWorkspaceSeam.ts index 68624d9f430..0b1c1fdd3a1 100644 --- a/src/node/services/taskWorkspaceSeam.ts +++ b/src/node/services/taskWorkspaceSeam.ts @@ -664,7 +664,7 @@ export interface WorkspaceProvisioningHost { subProjectPath?: string, pendingAutoTitle?: boolean, tags?: Record, - options?: { awaitMaterialization?: boolean } + options?: { awaitMaterialization?: boolean; skipDefaultUnrelatedWorkspaceConsent?: boolean } ): Promise>; sanitizeMaterializedTaskWorkspace( workspaceId: string, diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index 25c82fe159a..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 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 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.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..b90bee78fbb 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. @@ -21488,6 +21539,182 @@ describe("WorkspaceService init cancellation", () => { }), }; + // 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, + 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), + loadConfigOrDefault: mock(() => configState), + }; + + 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 + ); + + 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); + // 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(); + } + }); + + 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. + 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 = { @@ -21573,6 +21800,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. @@ -21582,7 +21827,9 @@ describe("WorkspaceService init cancellation", () => { { type: "local" }, undefined, // pendingAutoTitle: true mirrors the /fork-with-message flow. - true + true, + undefined, + { skipDefaultUnrelatedWorkspaceConsent: true } ); expect(result.success).toBe(true); @@ -21603,6 +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); + // Delegated targets are not opted in (yet): nothing persisted, announced or pending. + expect(newEntry?.unrelatedWorkspaceConsent).toBeUndefined(); + expect(result.data.metadata.unrelatedWorkspaceConsent).toBeUndefined(); + expect( + ( + workspaceService as unknown as { pendingDefaultUnrelatedConsent: Set } + ).pendingDefaultUnrelatedConsent.has(workspaceId) + ).toBe(false); } finally { createRuntimeSpy.mockRestore(); } @@ -22424,7 +22679,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"); @@ -22522,6 +22777,38 @@ 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( + 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"); @@ -22529,14 +22816,26 @@ 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]); + // ...nor while the rest of the fork's setup (goal inheritance) is still running. + expect(consentDuringGoalInheritance).toEqual([undefined]); const metadataAfterFork = await config.getAllWorkspaceMetadata(); 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"); + // 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({ @@ -22556,6 +22855,8 @@ describe("WorkspaceService fork", () => { turnsUsed: 1, }); } finally { + inheritSpy.mockRestore(); + sanitizeSpy.mockRestore(); orchestrateForkSpy.mockRestore(); copyPlanSpy.mockRestore(); runBackgroundInitSpy.mockRestore(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2e57800bac2..30b860a59f7 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1879,6 +1879,28 @@ 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. 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 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. + */ +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(); @@ -2943,6 +2965,15 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { */ private readonly pendingPluginSanitizations = new Set(); + /** + * 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(); + /** * Serializes persist + sanitize of a new host-local registration across * PROCESSES sharing this config root. pendingPluginSanitizations only @@ -3334,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); } @@ -5327,6 +5362,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; @@ -5368,6 +5404,11 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * read the checkout right after create() (and cannot wait for init) opt out. */ 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 (#4453). + */ + skipDefaultUnrelatedWorkspaceConsent?: boolean; } ): Promise> { if (tags != null) { @@ -5701,6 +5742,23 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); } } + 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 }; + } } finally { await releaseRegistrationLock?.(); this.pendingPluginSanitizations.delete(workspaceId); @@ -5743,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 { @@ -6074,6 +6133,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; @@ -7461,6 +7521,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"); @@ -7487,7 +7550,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) { @@ -7505,6 +7568,98 @@ 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 + * edit throws or does not persist, the workspace simply stays off. Returns the persisted + * generation, if any. + */ + private async grantCreationUnrelatedWorkspaceConsent( + projectPath: string, + workspaceId: string, + workspacePath: string, + /** Re-checked inside the serialized edit; false skips the grant. */ + shouldGrant: () => boolean = () => true + ): Promise { + let granted: string | undefined; + try { + await this.config.editConfig((freshConfig) => { + const entry = this.findFreshWorkspaceEntry(freshConfig, { + projectPath, + workspaceId, + workspacePath, + }); + if (!entry || !shouldGrant()) { + 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; + } + 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; + } + + /** + * 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. + */ + private async grantPendingDefaultUnrelatedWorkspaceConsent(workspaceId: string): Promise { + 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); + } + } catch (error) { + // 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), + }); + } finally { + this.pendingDefaultUnrelatedConsent.delete(workspaceId); + } + } + async setHeartbeatSettings( workspaceId: string, settings: WorkspaceHeartbeatSettingsUpdate @@ -11368,6 +11523,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..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,6 +294,167 @@ describe("WorkspaceService.setUnrelatedWorkspaceConsent", () => { }); }); +describe("WorkspaceService deferred-checkout default consent", () => { + let harness: Awaited>; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + mock.restore(); + await harness.cleanup(); + }); + + 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)); + + 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(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(); + }); + + 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 + ); + expect((await harness.service.setUnrelatedWorkspaceConsent(WORKSPACE_ID, false)).success).toBe( + true + ); + const published: unknown[] = []; + harness.service.on("metadata", (event: unknown) => published.push(event)); + + await grantPending(); + + expect(harness.persistedConsent()).toBeUndefined(); + expect(published).toEqual([]); + }); + + 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 (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); + }); +}); + 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..13c8f6d4d5c 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -2071,6 +2071,34 @@ describe("WorkspaceTurnManager", () => { }); }); + 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); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace }); + const { taskService } = createWorkspaceTurnManagerHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const result = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize the repo", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + + expect(result.success).toBe(true); + // 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({ + awaitMaterialization: true, + skipDefaultUnrelatedWorkspaceConsent: true, + }); + }); + 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..e621323d2d8 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -1232,8 +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. - { awaitMaterialization: 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 (#4453). + { awaitMaterialization: true, skipDefaultUnrelatedWorkspaceConsent: true } ); if (!createResult.success) { return Err(`Task.createWorkspaceTurn: workspace create failed (${createResult.error})`);