From f36e3a11c88dd0896f80bbf970ee00f69376352a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 11 Nov 2026 07:26:50 -0500 Subject: [PATCH] Add a foreground Claude provider for workflow roles --- packages/acp/src/provider.ts | 18 +++++++++ packages/acp/tests/provider.test.ts | 40 +++++++++++++++++++ packages/acp/tests/released-runtime.test.ts | 40 +++++++++++++++++++ packages/acp/vendor/acpx/MANIFEST.json | 8 ++-- packages/acp/vendor/acpx/PROVENANCE.md | 9 +++++ .../acp/vendor/acpx/generated/ipc-BbYFsKOj.js | 9 +++++ .../generated/queue-owner-runtime-B0mPqOu4.js | 14 ++++++- .../generated/session-options-BlnP3WAc.d.ts | 6 +++ packages/cli/src/agent-stack.ts | 10 +++++ scripts/tests/acpx-vendor.test.ts | 1 + specs/acp-client-spec.md | 22 ++++++++++ 11 files changed, 172 insertions(+), 5 deletions(-) diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 607377833..339923bda 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -1913,6 +1913,24 @@ function* useAcpxProviderState( prepared: Prepared, intent: EnsureIntent, ): Operation { + if (newSessionOptions?.disallowedTools !== undefined) { + const launchOptionsRecord = yield* until(store.load(prepared.sessionKey)); + if (launchOptionsRecord !== undefined) { + const stored = launchOptionsRecord.acpx?.session_options; + const denialsMatch = + JSON.stringify(stored?.disallowed_tools) === + JSON.stringify(newSessionOptions.disallowedTools); + const environmentMatches = Object.entries(newSessionOptions.env ?? {}).every( + ([key, value]) => stored?.env?.[key] === value, + ); + if (!denialsMatch || !environmentMatches) { + throw new AcpxSessionPolicyError( + "The retained session was not created with this provider's tool and environment controls. " + + "Preserve it; this profile does not retrofit existing conversations.", + ); + } + } + } if (prepared.kind === "existing") { return prepared.entry; } diff --git a/packages/acp/tests/provider.test.ts b/packages/acp/tests/provider.test.ts index 2732041f3..08011dba8 100644 --- a/packages/acp/tests/provider.test.ts +++ b/packages/acp/tests/provider.test.ts @@ -108,6 +108,46 @@ function* collectPrompt( } describe("Tier AP — ACPX provider", () => { + it("workflow-role controls refuse retained sessions that lack their creation settings", function* () { + const key = deriveSessionKey("scribe-cmd", CWD); + const controls = { + env: { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" }, + disallowedTools: ["ScheduleWakeup"], + }; + for (const compatible of [false, true]) { + yield* scoped(function* () { + yield* useFlatWorld(CWD); + const harness = createFakeRuntime(); + const record = makeRecord("scribe-cmd", CWD); + if (compatible) { + record.acpx = { + session_options: { env: controls.env, disallowed_tools: controls.disallowedTools }, + }; + } + const factory = createAcpxProvider({ + createRuntime: harness.create, + sessionStore: makeStore({ [key]: record }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), + newSessionOptions: controls, + }); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubCoordinator()); + let failure: unknown; + try { + yield* collectPrompt("continue the original assignment"); + } catch (error) { + failure = error; + } + expect(harness.turns.length).toBe(compatible ? 1 : 0); + if (!compatible) { + expect(harness.ensureCalls).toEqual([]); + expect(failure).toBeInstanceOf(AcpxSessionPolicyError); + } else { + expect(failure).toBeUndefined(); + } + }); + } + }); + it("1039 A3: a message-only upstream failure supplies no inferred diagnosis", function* () { const harness = createFakeRuntime(); harness.script({ result: { status: "failed", error: { message: "Internal error" } } }); diff --git a/packages/acp/tests/released-runtime.test.ts b/packages/acp/tests/released-runtime.test.ts index 05dc84d73..a801e61a8 100644 --- a/packages/acp/tests/released-runtime.test.ts +++ b/packages/acp/tests/released-runtime.test.ts @@ -341,6 +341,46 @@ describe( "released ACPX production consumers", { sanitizeOps: false, sanitizeResources: false }, () => { + it("workflow role deny lists and environment survive a cold session reopen", function* () { + const current = yield* world(); + const controls = { + disallowedTools: ["ScheduleWakeup"], + env: { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" }, + }; + for (const reopened of [false, true]) { + yield* scoped(function* () { + const runtime = createAcpRuntime({ + cwd: current.dir, + sessionStore: current.store, + agentRegistry: current.registry, + permissionMode: "deny-all", + }); + yield* ensure(() => until(runtime.shutdown())); + const handle = yield* until( + runtime.ensureSession({ + sessionKey: "controlled-role", + agent: "scribe", + mode: "persistent", + ...(reopened ? {} : { sessionOptions: controls }), + }), + ); + const turn = runtime.startTurn({ + handle, + text: "hello", + mode: "prompt", + requestId: reopened ? "continued" : "initial", + }); + for (const _event of yield* each(stream(turn.events))) { + yield* each.next(); + } + yield* until(turn.result); + const record = yield* until(current.store.load("controlled-role")); + expect(record?.acpx?.session_options?.disallowed_tools).toEqual(controls.disallowedTools); + expect(record?.acpx?.session_options?.env).toEqual(controls.env); + }); + } + }); + it("released child environments are snapshots, isolated and absent from retained state", function* () { const current = yield* world(); const parent = process.env.CLAUDE_CODE_EXECUTABLE; diff --git a/packages/acp/vendor/acpx/MANIFEST.json b/packages/acp/vendor/acpx/MANIFEST.json index 0cbcc9cc5..6f2e2dc31 100644 --- a/packages/acp/vendor/acpx/MANIFEST.json +++ b/packages/acp/vendor/acpx/MANIFEST.json @@ -31,7 +31,7 @@ { "path": "PROVENANCE.md", "kind": "provenance", - "sha256": "f1b825fc9603337eba9d12d607127892ba0ff33c54a20066f7c72bdaaa300ac2" + "sha256": "9766d8f7199310533aabaa970d83c9e6130ec5481312289758995fbccd8cf22b" }, { "path": "generated/agent-registry-Cz9fD9H1.js", @@ -51,12 +51,12 @@ { "path": "generated/ipc-BbYFsKOj.js", "kind": "generated", - "sha256": "423450379d7f13a5efb4d5e62e2f224e5420a96ea2b1e94efca5aef563a12c31" + "sha256": "34d624c639593611bce5849617f846abb4bb784e6228d00d90c523812005f841" }, { "path": "generated/queue-owner-runtime-B0mPqOu4.js", "kind": "generated", - "sha256": "e2bd0fd71b49e51697d42e7f4dae7a97aee525dfe17ddbeab8efae2d93951b6c" + "sha256": "86ba2dd9430627db263f1c986b8733f34d37bd06bf6cf6a3ae600d63e3b756a5" }, { "path": "generated/runtime.d.ts", @@ -71,7 +71,7 @@ { "path": "generated/session-options-BlnP3WAc.d.ts", "kind": "generated", - "sha256": "e4484e22e1549b1297d276370c5542f8ef5cf569cdadff347f0c39111817b7aa" + "sha256": "d0df6fcd566d0179151aa7fa4aef04e25dd4042461d2609384b9cf8843ebd854" }, { "path": "generated/watch-bQXyUy3z.js", diff --git a/packages/acp/vendor/acpx/PROVENANCE.md b/packages/acp/vendor/acpx/PROVENANCE.md index 702a1ff8d..1e2511dd0 100644 --- a/packages/acp/vendor/acpx/PROVENANCE.md +++ b/packages/acp/vendor/acpx/PROVENANCE.md @@ -246,3 +246,12 @@ Keep the materialization patch and packaging exceptions until a released API preserves explicit backend acceptance, non-asserting occupancy and the joined asserting-save barrier. Two released seams never authorize deleting that third contract or the complete vendor directory. + +### Claude workflow-role tool controls + +The carried session-option path forwards `disallowedTools` to Claude's existing +SDK option and retains it as `disallowed_tools` beside the session environment. +Normalization, record readback and IPC keep that field. This changes launch-option +plumbing only; the Claude adapter and SDK producer inputs are unchanged. The +`claude-workflow-role` CLI profile selects these options for workflow roles. +Ordinary interactive sessions retain their original defaults. diff --git a/packages/acp/vendor/acpx/generated/ipc-BbYFsKOj.js b/packages/acp/vendor/acpx/generated/ipc-BbYFsKOj.js index 0f0c404f3..0123db38b 100644 --- a/packages/acp/vendor/acpx/generated/ipc-BbYFsKOj.js +++ b/packages/acp/vendor/acpx/generated/ipc-BbYFsKOj.js @@ -1696,6 +1696,8 @@ function assignParsedSessionOptions(state, raw) { const parsedSessionOptions = {}; if (typeof sessionOptions.model === "string") parsedSessionOptions.model = sessionOptions.model; if (isStringArray(sessionOptions.allowed_tools)) parsedSessionOptions.allowed_tools = [...sessionOptions.allowed_tools]; + // XMD workflow role controls: preserve the explicit tool deny list. + if (isStringArray(sessionOptions.disallowed_tools)) parsedSessionOptions.disallowed_tools = [...sessionOptions.disallowed_tools]; if (isPositiveInteger$1(sessionOptions.max_turns)) parsedSessionOptions.max_turns = sessionOptions.max_turns; assignSessionOptionSystemPrompt(parsedSessionOptions, sessionOptions.system_prompt); const env = parseStringMap(sessionOptions.env); @@ -3935,6 +3937,13 @@ function parseSessionOptions(value) { const sessionOptions = {}; if (!assignSessionModel(sessionOptions, record.model)) return null; if (!assignSessionAllowedTools(sessionOptions, record.allowedTools)) return null; + // XMD workflow role controls: preserve the explicit tool deny list. + if (record.disallowedTools != null) { + // XMD workflow role controls: preserve the explicit tool deny list. + if (!isStringArray(record.disallowedTools)) return null; + // XMD workflow role controls: preserve the explicit tool deny list. + sessionOptions.disallowedTools = [...record.disallowedTools]; + } if (!assignSessionMaxTurns(sessionOptions, record.maxTurns)) return null; if (!assignSessionSystemPrompt(sessionOptions, record.systemPrompt)) return null; if (!assignSessionEnv(sessionOptions, record.env)) return null; diff --git a/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js b/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js index f65ddb85c..33f02df08 100644 --- a/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js +++ b/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js @@ -1266,6 +1266,9 @@ function resolveClaudeCodeSettingSources(env = process.env) { function assignClaudeCodeOptions(target, options) { if (typeof options.model === "string" && options.model.trim().length > 0) target.model = options.model; if (Array.isArray(options.allowedTools)) target.allowedTools = [...options.allowedTools]; + // XMD role tool denials reach the supported Claude SDK option. + // XMD workflow role controls: preserve the explicit tool deny list. + if (Array.isArray(options.disallowedTools)) target.disallowedTools = [...options.disallowedTools]; if (typeof options.maxTurns === "number") target.maxTurns = options.maxTurns; } function assignClaudeCodeSystemPrompt(target, systemPrompt) { @@ -4420,6 +4423,8 @@ function cloneSessionOptions(options) { return { model: options.model, allowed_tools: options.allowed_tools ? [...options.allowed_tools] : void 0, + // XMD workflow role controls: keep denials when turn state is cloned. + disallowed_tools: options.disallowed_tools ? [...options.disallowed_tools] : void 0, max_turns: options.max_turns, ...options.system_prompt !== void 0 ? { system_prompt: cloneSystemPromptOption(options.system_prompt) } : {}, ...options.env !== void 0 ? { env: { ...options.env } } : {} @@ -4672,6 +4677,8 @@ function mergeSessionOptions(preferred, fallback) { const merged = { ...fallback }; assignDefinedOption(merged, "model", preferred?.model); assignDefinedOption(merged, "allowedTools", preferred?.allowedTools); + // XMD workflow role controls: preserve the explicit tool deny list. + assignDefinedOption(merged, "disallowedTools", preferred?.disallowedTools); assignDefinedOption(merged, "maxTurns", preferred?.maxTurns); assignDefinedOption(merged, "systemPrompt", preferred?.systemPrompt); assignDefinedOption(merged, "env", mergeEnvRecords(fallback?.env, preferred?.env)); @@ -4710,6 +4717,8 @@ function sessionOptionsFromRecord(record) { const sessionOptions = {}; assignDefinedOption(sessionOptions, "model", nonEmptyString(stored.model)); assignDefinedOption(sessionOptions, "allowedTools", storedAllowedTools(stored.allowed_tools)); + // XMD workflow role controls: preserve the explicit tool deny list. + assignDefinedOption(sessionOptions, "disallowedTools", storedAllowedTools(stored.disallowed_tools)); assignDefinedOption(sessionOptions, "maxTurns", storedMaxTurns(stored.max_turns)); assignDefinedOption(sessionOptions, "systemPrompt", normalizeSystemPromptOption(stored.system_prompt)); assignDefinedOption(sessionOptions, "env", storedEnvRecord(stored.env)); @@ -4719,6 +4728,8 @@ function persistedSessionOptions(options) { const next = { model: nonEmptyString(options.model), allowed_tools: Array.isArray(options.allowedTools) ? [...options.allowedTools] : void 0, + // XMD workflow role controls: preserve the explicit tool deny list. + disallowed_tools: Array.isArray(options.disallowedTools) ? [...options.disallowedTools] : void 0, max_turns: typeof options.maxTurns === "number" ? options.maxTurns : void 0, system_prompt: normalizeSystemPromptOption(options.systemPrompt), env: storedEnvRecord(options.env) @@ -4726,7 +4737,8 @@ function persistedSessionOptions(options) { return hasPersistedSessionOptions(next) ? next : void 0; } function hasPersistedSessionOptions(options) { - return options.model !== void 0 || options.allowed_tools !== void 0 || options.max_turns !== void 0 || options.system_prompt !== void 0 || options.env !== void 0; + // XMD workflow role controls: preserve the explicit tool deny list. + return options.model !== void 0 || options.allowed_tools !== void 0 || options.disallowed_tools !== void 0 || options.max_turns !== void 0 || options.system_prompt !== void 0 || options.env !== void 0; } function storedEnvRecord(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) return; diff --git a/packages/acp/vendor/acpx/generated/session-options-BlnP3WAc.d.ts b/packages/acp/vendor/acpx/generated/session-options-BlnP3WAc.d.ts index dbbac25d3..7a31d29b1 100644 --- a/packages/acp/vendor/acpx/generated/session-options-BlnP3WAc.d.ts +++ b/packages/acp/vendor/acpx/generated/session-options-BlnP3WAc.d.ts @@ -166,6 +166,8 @@ type AcpClientOptions = { sessionOptions?: { model?: string; allowedTools?: string[]; + // XMD workflow role controls: preserve the explicit tool deny list. + disallowedTools?: string[]; maxTurns?: number; systemPrompt?: string | { append: string; @@ -282,6 +284,8 @@ type SessionAcpxState = { session_options?: { model?: string; allowed_tools?: string[]; + // XMD workflow role controls: preserve the explicit tool deny list. + disallowed_tools?: string[]; max_turns?: number; system_prompt?: string | { append: string; @@ -336,6 +340,8 @@ type SystemPromptOption = string | { type SessionAgentOptions = { model?: string; allowedTools?: string[]; + // XMD workflow role controls: preserve the explicit tool deny list. + disallowedTools?: string[]; maxTurns?: number; systemPrompt?: SystemPromptOption; /** diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index fe5527e79..fdeac9df5 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -194,6 +194,16 @@ export function* installAgentProviderStack( ): Operation { const acpx = selectedFactory ?? createAcpxProvider(acp ?? hostAcpDependencies(stack)); yield* registerAgentProvider("acpx", acpx); + yield* registerAgentProvider( + "claude-workflow-role", + createAcpxProvider({ + ...(acp ?? hostAcpDependencies(stack)), + newSessionOptions: { + env: { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" }, + disallowedTools: ["ScheduleWakeup", "Monitor"], + }, + }), + ); // The trusted host selects its own root provider by name. Document-level // selection goes through the installation protocol; this is the host saying diff --git a/scripts/tests/acpx-vendor.test.ts b/scripts/tests/acpx-vendor.test.ts index 344bd3c25..f838626eb 100644 --- a/scripts/tests/acpx-vendor.test.ts +++ b/scripts/tests/acpx-vendor.test.ts @@ -41,6 +41,7 @@ const SEAMS: readonly string[] = [ "XMD prompt diagnostic:", "XMD prompt usage:", "XMD requested model:", + "XMD workflow role controls:", "XMD exact-model replay:", ]; diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index ceaea7074..77b1504f1 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -769,6 +769,28 @@ Registering a provider only makes a factory **resolvable**; it does not install that provider into the `Agent` Api. Operations still report a missing provider until one is installed as a root provider or by AgentProvider. +### Claude workflow-role profile + +The CLI registers `claude-workflow-role` alongside ordinary `acpx`. The +profile uses the same carried adapter, session store and host identity +coordination as ordinary calls. Interactive Sidekick configuration is unchanged. + +The profile sets `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1` in the child session +environment and supplies `disallowedTools: ["ScheduleWakeup", "Monitor"]` +through ACPX to Claude's supported SDK option. ACPX retains the deny list with +the session's creation options, restores it on reconnect and carries it through +IPC. These settings remove the native shell/subagent background parameter, +disable automatic shell backgrounding and deny both tools. Other background +services and MCP tasks can still be available. The role still owns every process +it starts and must join it before returning its result. A foreground timeout +fails the command and leaves the role result unaccepted. + +Session creation options do not retrofit an existing conversation. Before ensure +or prompt, a provider with an explicit deny list compares that list and every +selected environment value with the retained session options. A missing or +different value refuses rather than silently reconfiguring, replacing or +resetting the session. Fresh sessions record these settings on creation. + ### Seeded configuration `installAgentComponents({ defaultAgent, permissionMode })` seeds the