Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/acp/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1913,6 +1913,24 @@ function* useAcpxProviderState(
prepared: Prepared,
intent: EnsureIntent,
): Operation<ManagedSession> {
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;
}
Expand Down
40 changes: 40 additions & 0 deletions packages/acp/tests/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } });
Expand Down
40 changes: 40 additions & 0 deletions packages/acp/tests/released-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions packages/acp/vendor/acpx/MANIFEST.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
{
"path": "PROVENANCE.md",
"kind": "provenance",
"sha256": "f1b825fc9603337eba9d12d607127892ba0ff33c54a20066f7c72bdaaa300ac2"
"sha256": "9766d8f7199310533aabaa970d83c9e6130ec5481312289758995fbccd8cf22b"
},
{
"path": "generated/agent-registry-Cz9fD9H1.js",
Expand All @@ -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",
Expand All @@ -71,7 +71,7 @@
{
"path": "generated/session-options-BlnP3WAc.d.ts",
"kind": "generated",
"sha256": "e4484e22e1549b1297d276370c5542f8ef5cf569cdadff347f0c39111817b7aa"
"sha256": "d0df6fcd566d0179151aa7fa4aef04e25dd4042461d2609384b9cf8843ebd854"
},
{
"path": "generated/watch-bQXyUy3z.js",
Expand Down
9 changes: 9 additions & 0 deletions packages/acp/vendor/acpx/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions packages/acp/vendor/acpx/generated/ipc-BbYFsKOj.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 } } : {}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand All @@ -4719,14 +4728,17 @@ 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)
};
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
/**
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/agent-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,16 @@ export function* installAgentProviderStack(
): Operation<void> {
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
Expand Down
1 change: 1 addition & 0 deletions scripts/tests/acpx-vendor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
];

Expand Down
22 changes: 22 additions & 0 deletions specs/acp-client-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading