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})`);