From 376e80870700e0229493d5dbb5ce23c2bcf12e7f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 17:35:49 -0700 Subject: [PATCH 1/3] CL-6448: assert openai-compatible body keeps tools and history Pin the assembled wire request so a stripped tools or history payload fails, and cover body-step grant collapse plus durable conversation storage keyed by stepId across turn__n runs. --- .../step-env.test.ts | 67 ++++++- .../test/body-step-grants-collapse.test.ts | 79 ++++++++ .../test/body-turn-request-assembly.test.ts | 186 ++++++++++++++++++ ...ubstrate-factory-suspendable-child.test.ts | 85 ++++++++ 4 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 apps/sidecar/test/body-step-grants-collapse.test.ts create mode 100644 apps/sidecar/test/body-turn-request-assembly.test.ts diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts index aaefa38b1..0ffc96c2b 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts @@ -52,7 +52,6 @@ function buildEnvDeps(dataDir: string) { hubArtifactsUrl: "https://hub.example.com", sidecarToken: "sc-token", adapters: { resolve: () => undefined } as never, - toolless: true, definitionId: "wfd_capability_owner", }; } @@ -96,7 +95,7 @@ test("the built step env carries the deploying definition's own definitionId and ); }); -test("a toolless body-step env still carries definitionId, so the binding is not tool-materialization-gated", async () => { +test("a body-step env with no staged deploy tree still carries definitionId, so the binding is not tool-materialization-gated", async () => { const dataDir = makeTmpDataDir(); const buildEnv = createSidecarStepBuildEnv({ ...buildEnvDeps(dataDir), @@ -114,6 +113,70 @@ test("a toolless body-step env still carries definitionId, so the binding is not ); }); +// CL-6448: the body-turn history seam. A section body runs each message as +// its own child run (`turn__`), so conversation continuity depends on +// the env builder resolving `storage` through the per-agent durable +// registry keyed by the STABLE stepId — never the per-run isogit store a +// changing runId would reset every turn. +test("with a durable-conversation registry, envs built for different runIds share one storage keyed by stepId", async () => { + const dataDir = makeTmpDataDir(); + const acquired: string[] = []; + const sharedStorage = { marker: "durable-store" }; + const registry = { + acquire: (key: string) => { + acquired.push(key); + return Promise.resolve({ storage: sharedStorage } as never); + }, + get: () => { + throw new Error("unused"); + }, + peek: () => undefined, + }; + const buildEnv = createSidecarStepBuildEnv({ + ...buildEnvDeps(dataDir), + durableConversation: registry as never, + }); + const sourcesRef: SourcesSnapshotRef = { + current: { + step_1: [{ id: "src_1", provider: "anthropic", model: "claude" }], + }, + } as unknown as SourcesSnapshotRef; + + const turn1 = stepInvokeRequest(); + turn1.authzContext.runId = "turn__0"; + const turn2 = stepInvokeRequest(); + turn2.authzContext.runId = "turn__1"; + + const env1 = await buildEnv(turn1, sourcesRef); + const env2 = await buildEnv(turn2, sourcesRef); + + expect(acquired).toEqual(["step_1", "step_1"]); + expect(env1.storage).toBe(sharedStorage as never); + expect(env2.storage).toBe(env1.storage); +}); + +// CL-6448: without the registry, per-run isogit stores stay per-run — the +// multi-step cold path's behavior is unchanged. +test("without a durable-conversation registry, envs built for different runIds get distinct storage", async () => { + const dataDir = makeTmpDataDir(); + const buildEnv = createSidecarStepBuildEnv(buildEnvDeps(dataDir)); + const sourcesRef: SourcesSnapshotRef = { + current: { + step_1: [{ id: "src_1", provider: "anthropic", model: "claude" }], + }, + } as unknown as SourcesSnapshotRef; + + const turn1 = stepInvokeRequest(); + turn1.authzContext.runId = "turn__0"; + const turn2 = stepInvokeRequest(); + turn2.authzContext.runId = "turn__1"; + + const env1 = await buildEnv(turn1, sourcesRef); + const env2 = await buildEnv(turn2, sourcesRef); + + expect(env1.storage).not.toBe(env2.storage); +}); + test("the built step env forwards the summarize-older-turns compactor (CL-6204) like the other env fields above", async () => { const dataDir = makeTmpDataDir(); const buildEnv = createSidecarStepBuildEnv(buildEnvDeps(dataDir)); diff --git a/apps/sidecar/test/body-step-grants-collapse.test.ts b/apps/sidecar/test/body-step-grants-collapse.test.ts new file mode 100644 index 000000000..141588d7a --- /dev/null +++ b/apps/sidecar/test/body-step-grants-collapse.test.ts @@ -0,0 +1,79 @@ +// CL-6448: an onTrigger body step's tool calls authorize against the +// deployment's grants. The credentials snapshot is keyed by the PARENT +// stepOrder, so the body's own stepId (`reply`) never appears in it; +// for a single-step deployment the sole entry IS the deployment's +// grant set and the lookup collapses to it (mirroring the head/step +// collapse the body's tool materialization already uses). A multi-step +// snapshot stays strict — an unknown stepId is ambiguous and throws. +import { expect, test } from "bun:test"; + +import { createCredentialsBackedAuthorize } from "@intx/workflow-host"; + +const HEAD_GRANTS = [{ resource: "tool:@corbits/x/t", effect: "allow" }]; + +function evaluatorRecorder(seen: { grants?: readonly unknown[] }) { + return (call: { grants: readonly unknown[] }) => { + seen.grants = call.grants; + return Promise.resolve({ effect: "allow" as const }); + }; +} + +test("a body stepId absent from a single-step snapshot collapses to the sole entry's grants", async () => { + const seen: { grants?: readonly unknown[] } = {}; + const authorize = createCredentialsBackedAuthorize( + { + current: { + steps: [ + { + stepId: "turn", + address: "run_1@local", + grants: HEAD_GRANTS, + contentHash: "h", + }, + ], + }, + } as never, + evaluatorRecorder(seen) as never, + ); + + const result = await authorize("tool:@corbits/x/t", "invoke", { + stepId: "reply", + runId: "turn__3", + attempt: 1, + } as never); + + expect(result.effect).toBe("allow"); + expect(seen.grants).toBe(HEAD_GRANTS); +}); + +test("an unknown stepId against a multi-step snapshot stays a loud miss", async () => { + const authorize = createCredentialsBackedAuthorize( + { + current: { + steps: [ + { + stepId: "a", + address: "run_1-a@local", + grants: [], + contentHash: "h", + }, + { + stepId: "b", + address: "run_1-b@local", + grants: [], + contentHash: "h", + }, + ], + }, + } as never, + evaluatorRecorder({}) as never, + ); + + await expect( + authorize("tool:@corbits/x/t", "invoke", { + stepId: "reply", + runId: "r", + attempt: 1, + } as never), + ).rejects.toThrow("credentialsSnapshot has no entry for stepId reply"); +}); diff --git a/apps/sidecar/test/body-turn-request-assembly.test.ts b/apps/sidecar/test/body-turn-request-assembly.test.ts new file mode 100644 index 000000000..e8ea214dd --- /dev/null +++ b/apps/sidecar/test/body-turn-request-assembly.test.ts @@ -0,0 +1,186 @@ +// CL-6448 regression guard at the request-assembly layer. +// +// The section-body (chat turn) defect was invisible to every unit above +// the wire: the agent ran, the reply streamed, and only the outbound +// inference request showed the loss — `tools` absent and `messages` +// holding just the system prompt plus the latest user message. This +// suite pins the contract at exactly that layer: a real `createAgent` +// send with a captured `deps.fetch` asserts the literal HTTP body an +// OpenAI-compatible provider receives carries BOTH the prior +// conversation turns the context store restored AND the agent's +// declared tools. +import { expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + createAgent, + createDefaultDirectorRegistry, + defineAgent, + defineTool, +} from "@intx/agent"; +import { createDependencies } from "@intx/inference"; +import { createBuiltinRegistry } from "@intx/inference/providers"; + +const PRIOR_TURNS = [ + { + role: "user", + content: [{ type: "text", text: "My favorite color is teal." }], + }, + { + role: "assistant", + content: [{ type: "text", text: "Noted: teal." }], + }, +]; + +function contextStoreStub() { + return { + load: () => + Promise.resolve({ + turns: PRIOR_TURNS, + pendingOperations: [], + tokenUsage: undefined, + }), + writeTurns: () => Promise.resolve(), + writePrompt: () => Promise.resolve(), + writeResponse: () => Promise.resolve(), + writeManifest: () => Promise.resolve(), + writeMetadata: () => Promise.resolve(), + writeBlob: () => Promise.resolve(), + commit: () => Promise.resolve({ commitId: "stub" }), + record: () => Promise.resolve(), + }; +} + +function sseResponse(): Response { + const chunk = { + id: "c1", + object: "chat.completion.chunk", + created: 1, + model: "stub-model", + choices: [ + { + index: 0, + delta: { role: "assistant", content: "ok" }, + finish_reason: null, + }, + ], + }; + const stop = { + id: "c1", + object: "chat.completion.chunk", + created: 1, + model: "stub-model", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + const body = [ + `data: ${JSON.stringify(chunk)}`, + "", + `data: ${JSON.stringify(stop)}`, + "", + "data: [DONE]", + "", + "", + ].join("\n"); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +test("an openai-compatible turn's wire request carries the restored history and the declared tools", async () => { + const captured: { url?: string; body?: unknown } = {}; + const fetchStub = (url: string | URL | Request, init?: RequestInit) => { + captured.url = String(url); + captured.body = JSON.parse(String(init?.body)); + return Promise.resolve(sseResponse()); + }; + + const echoDefinition = { + name: "@corbits/test-tools/echo", + description: "Echo the input back.", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + }, + }; + const echoFactory = defineTool({ + id: "@corbits/test-tools/echo", + definitions: [echoDefinition], + factory: () => ({ + definitions: [echoDefinition], + run: (call: { callId: string }) => + Promise.resolve({ + callId: call.callId, + content: [{ type: "text" as const, text: "ok" }], + isError: false, + }), + }), + } as never); + + const definition = defineAgent({ + id: "body-turn-assembly-test", + systemPrompt: "You are the assembly-regression fixture.", + tools: [echoFactory], + capabilities: [], + inference: { sources: [{ provider: "openai", model: "stub-model" }] }, + }); + + const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "cl6448-assembly-")); + const storage = contextStoreStub(); + const agent = await createAgent( + definition as never, + { + sources: [ + { + id: "src-openai-stub", + provider: "openai", + baseURL: "http://inference.stub.invalid/v1", + apiKey: "stub-key", + model: "stub-model", + }, + ], + defaultSource: "src-openai-stub", + storage, + audit: storage, + workdir, + directors: createDefaultDirectorRegistry(), + authorize: () => Promise.resolve({ effect: "allow" }), + deps: { + ...createDependencies(createBuiltinRegistry()), + fetch: fetchStub, + }, + } as never, + ); + + try { + await agent.send("What is my favorite color?"); + } finally { + await agent.close(); + } + + expect(captured.url).toBe( + "http://inference.stub.invalid/v1/chat/completions", + ); + const request = captured.body as { + messages: { role: string; content: unknown }[]; + tools?: { function: { name: string } }[]; + }; + + // History: every prior turn the context store restored precedes the + // new user message on the wire. + const serialized = JSON.stringify(request.messages); + expect(serialized).toContain("My favorite color is teal."); + expect(serialized).toContain("Noted: teal."); + expect(serialized).toContain("What is my favorite color?"); + const roles = request.messages.map((m) => m.role); + expect(roles[0]).toBe("system"); + expect(roles).toContain("assistant"); + + // Tools: the declared tool rides the request (name is + // provider-encoded, so match on the stable suffix). + expect(request.tools).toBeDefined(); + expect(request.tools?.length).toBe(1); + expect(request.tools?.[0]?.function.name).toContain("echo"); +}); diff --git a/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts b/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts index 3a89aaf54..da2b0c108 100644 --- a/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts +++ b/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts @@ -277,6 +277,91 @@ describe("createSidecarSpawnSuspendableChild", () => { expect(seen.sources).toEqual(bodySources); }); + // CL-6448: the body-turn tool seam. The spawn input threads the parent + // child's credentials-backed authorize and live credential wiring; the + // body invoker must receive exactly those, so a body agent's tool calls + // gate through the parent's per-step grant snapshot instead of the + // throwing stub. + test("the spawn input's authorize and credentialWiring reach the body invoker", async () => { + const substrate = await makeSubstrate("suspendable-body-authorize-"); + const dataDir = await makeTempDir("suspendable-body-authz-datadir-"); + const sourcesDir = path.join( + dataDir, + "assets", + "workflow", + "body-wf-authz", + ); + await fs.promises.mkdir(sourcesDir, { recursive: true }); + await fs.promises.writeFile( + path.join(sourcesDir, "sources.json"), + JSON.stringify({ + s: [ + { + id: "s", + provider: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-body", + model: "claude-3-5", + }, + ], + }), + ); + + const threadedAuthorize = () => + Promise.resolve({ effect: "allow" as const }); + const threadedWiring = { + materialRef: { current: null }, + resolveStepGrants: () => [], + }; + const seen: { authorize?: unknown; credentialWiring?: unknown } = {}; + const bodyInvokeStep: SidecarBodyStepInvoker = async ( + _req, + authorize, + _sourcesRef, + _onEvent, + credentialWiring, + ) => { + seen.authorize = authorize; + seen.credentialWiring = credentialWiring; + return { output: { done: true } }; + }; + const spawn = createSidecarSpawnSuspendableChild({ + substrate, + workflowRunRepoId: WORKFLOW_RUN_REPO_ID, + workflowRunRef: REF, + principal: PRINCIPAL, + scheduler: createInMemoryScheduler({ + repoStore: createInMemoryRepoStore(), + clock: () => new Date(), + }), + invokeStep: () => { + throw new Error("must route through bodyInvokeStep"); + }, + bodyInvokeStep, + dataDir, + }); + + const handle = await spawn( + { + definition: bodyDefinition("body-wf-authz"), + definitionRef: REF, + childRunId: "run-body-3", + input: { text: "event-3" }, + parentRunId: "run-parent", + parentStepId: "section", + signal: new AbortController().signal, + authorize: threadedAuthorize as never, + credentialWiring: threadedWiring as never, + }, + () => undefined, + ); + + const terminal = await handle.next(); + expect(terminal.kind).toBe("terminal"); + expect(seen.authorize).toBe(threadedAuthorize); + expect(seen.credentialWiring).toBe(threadedWiring); + }); + test("a parent abort while parked cancels the child and surfaces a terminal", async () => { const substrate = await makeSubstrate("suspendable-abort-"); const spawn = makeSpawner(substrate, suspendThenComplete({})); From 1267e6eb22624d453f6623f90dc8a88145ff2a69 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 17:35:57 -0700 Subject: [PATCH 2/3] CL-6448: keep tools and history on openai-compatible body turns Thread parent authorize and credentialWiring through the body spawn seam, drop the toolless body-env skip so staged tools materialize, and restore conversation via the durable store keyed by stepId. --- apps/sidecar/src/conversation-state.ts | 13 +++- .../child-runtime.ts | 36 +++++++-- .../src/workflow-substrate-factory/index.ts | 78 +++++++++---------- .../workflow-substrate-factory/step-env.ts | 48 ++++-------- .../workflow-host/src/adapters/spawn-child.ts | 25 ++++++ .../intx/workflow-host/src/child/run-child.ts | 42 +++++++--- 6 files changed, 155 insertions(+), 87 deletions(-) diff --git a/apps/sidecar/src/conversation-state.ts b/apps/sidecar/src/conversation-state.ts index cfa12acc5..c09248a5b 100644 --- a/apps/sidecar/src/conversation-state.ts +++ b/apps/sidecar/src/conversation-state.ts @@ -649,6 +649,13 @@ export interface DurableConversationRegistryOpts { export interface DurableConversationRegistry { acquire(key: string): Promise; get(key: string): DurableConversationStore; + /** + * The store for `key` if one has been acquired, else `undefined`. + * The body-turn mirror (CL-6448) runs in a `finally` that must not + * mask a build failure with `get`'s throw when the env builder never + * reached its acquire. + */ + peek(key: string): DurableConversationStore | undefined; } export function createDurableConversationRegistry( @@ -703,6 +710,10 @@ export function createDurableConversationRegistry( return promise; } + function peek(key: string): DurableConversationStore | undefined { + return stores.get(key); + } + function get(key: string): DurableConversationStore { const store = stores.get(key); if (store === undefined) { @@ -713,7 +724,7 @@ export function createDurableConversationRegistry( return store; } - return { acquire, get }; + return { acquire, get, peek }; } interface SnapshotMetadataValue { diff --git a/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts b/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts index c472fe6c4..6eb10138d 100644 --- a/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts +++ b/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts @@ -37,6 +37,7 @@ import { createWorkflowRunBlobSubstrate, createWorkflowRunRepoStore, createInMemorySpawnChild, + type CredentialWiring, type RunChildWorkflow, type RunSuspendableChild, type SourcesSnapshotRef, @@ -57,16 +58,19 @@ import { * from the body's on-disk `sources.json`, disjoint from the top-level's * mutable source table so a top-level source rotation never leaks into a * body. It also carries an `onEvent` funnel that attributes the body child's - * live inference events to the body run id on the hub timeline, and the - * child env's workflow-typed `authorize` so any tool gate a (guaranteed - * toolless) body agent would somehow reach fails loud through the child's - * throwing authorize stub. + * live inference events to the body run id on the hub timeline, the + * workflow-typed `authorize` the spawn seam threaded from the parent child + * (CL-6448: the credentials-backed authorize, so a body agent's tool calls + * gate through the same per-step grant snapshot a top-level step's do), + * and the parent's live `CredentialWiring` for tool bundles that declare a + * `credentials` capability. */ export type SidecarBodyStepInvoker = ( req: StepInvokeRequest, authorize: WorkflowAuthorizeFn, sourcesRef: SourcesSnapshotRef, onEvent: (event: InferenceEvent) => void, + credentialWiring?: CredentialWiring, ) => Promise; /** @@ -320,7 +324,15 @@ export function createSidecarSpawnSuspendableChild( const runChild = createSidecarRunChild(deps); return async ( - { definition, childRunId, input, resumeFromEvents, signal }, + { + definition, + childRunId, + input, + resumeFromEvents, + signal, + authorize: threadedAuthorize, + credentialWiring, + }, onEvent, ) => { const { @@ -361,9 +373,19 @@ export function createSidecarSpawnSuspendableChild( ), }; const bodyInvokeStep = deps.bodyInvokeStep; - const authorize = baseEnv.authorize; + // CL-6448: prefer the parent child's credentials-backed authorize + // the spawn seam threaded in; a spawn that carried none keeps the + // fail-loud stub, so an unthreaded tool gate still surfaces + // precisely rather than silently authorizing. + const authorize = threadedAuthorize ?? baseEnv.authorize; invokeStep = (req) => - bodyInvokeStep(req, authorize, bodySourcesRef, onEvent); + bodyInvokeStep( + req, + authorize, + bodySourcesRef, + onEvent, + credentialWiring, + ); } // FIFO the caller drains via `next()`: each entry is either an approval diff --git a/apps/sidecar/src/workflow-substrate-factory/index.ts b/apps/sidecar/src/workflow-substrate-factory/index.ts index f5e7893c1..737320c0a 100644 --- a/apps/sidecar/src/workflow-substrate-factory/index.ts +++ b/apps/sidecar/src/workflow-substrate-factory/index.ts @@ -360,7 +360,6 @@ export function createSidecarSubstrateFactory( outboundMailBridge: env.outboundMailBridge, cache: stepToolCache, adapters: childAdapterRegistry, - toolless: false, hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL), sidecarToken: validated.SIDECAR_TOKEN, definitionId: validated.WORKFLOW_DEFINITION_ID, @@ -428,49 +427,50 @@ export function createSidecarSubstrateFactory( new ChildStepNotImplementedError(req.agent.id, req.authzContext.stepId), ); - // onTrigger BODY step invoker. Unlike a childWorkflow child, an - // onTrigger section body IS staged: its definition and per-step - // inference sources land on disk beside each other at deploy, and its - // agents are guaranteed toolless (a tool-bearing body agent is rejected - // at deploy). So a body agent step runs for real through the same - // `createWorkflowStepInvoker` the top level uses -- built COLD per - // invocation (no warm registry: a body is a fresh run per section - // event, so no durableConversation, warmCache, or run-boundary mirror) - // and TOOLLESS (the build-env skips tool materialization, so a body - // stepId colliding with a parent step id can never read the parent's - // tools). The per-body `sourcesRef` is threaded in per spawn, disjoint - // from the top level's. `onEvent` is the per-run event funnel from the - // parent run's event channel, so a body agent's live inference events - // reach the hub stream (per-run attribution stays durable via + // onTrigger BODY step invoker (CL-6448). Unlike a childWorkflow child, + // an onTrigger section body IS staged: its definition and per-step + // inference sources land on disk beside each other at deploy. A body + // agent step runs for real through the same `createWorkflowStepInvoker` + // and the same `buildStepEnv` the top level uses -- so a warm-kept + // section deployment's body turns share the per-agent durable + // conversation store (each turn's agent loads every prior turn, keyed + // by the body's stable stepId across `turn__` occurrences) and + // materialize the deployment's staged tool manifest (the head/step + // collapse reads the folded launch's own staged pins for a single-step + // deployment). The agent itself stays cold per occurrence; the mirror + // in the `finally` below is the body path's run-boundary durability + // flush, matching the warm top-level path's `onRunBoundary`. The + // per-body `sourcesRef` is threaded in per spawn, disjoint from the + // top level's. `onEvent` is the per-run event funnel from the parent + // run's event channel, so a body agent's live inference events reach + // the hub stream (per-run attribution stays durable via // runs//events/). - const coldBodyBuildStepEnv = createSidecarStepBuildEnv({ - dataDir: validated.SIDECAR_DATA_DIR, - workflowRunRepoId, - signer: conversationSigner, - registries: parseToolRegistries(validated.SIDECAR_TOOL_REGISTRIES), - mailboxAddress: env.spawn.mailboxAddress, - stepCount: env.spawn.stepCount, - outboundMailBridge: env.outboundMailBridge, - cache: stepToolCache, - adapters: childAdapterRegistry, - toolless: true, - hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL), - sidecarToken: validated.SIDECAR_TOKEN, - definitionId: validated.WORKFLOW_DEFINITION_ID, - }); - const bodyInvokeStep: SidecarBodyStepInvoker = ( + const bodyInvokeStep: SidecarBodyStepInvoker = async ( req, authorize, sourcesRef, onEvent, - ) => - createWorkflowStepInvoker({ - workflowAuthorize: authorize, - buildEnv: (buildReq) => coldBodyBuildStepEnv(buildReq, sourcesRef), - agentFactory: stepAgentFactory, - sourcesRef, - onEvent, - })(req); + credentialWiring, + ) => { + try { + return await createWorkflowStepInvoker({ + workflowAuthorize: authorize, + buildEnv: (buildReq) => + buildStepEnv(buildReq, sourcesRef, credentialWiring), + agentFactory: stepAgentFactory, + sourcesRef, + onEvent, + })(req); + } finally { + const bodyStepId = req.authzContext.stepId; + if (durableConversation !== undefined && bodyStepId !== undefined) { + // `peek`, not `get`: a build failure before the env's acquire + // must surface as itself, not as the registry's missing-store + // throw. + await durableConversation.peek(bodyStepId)?.mirrorToSubstrate(); + } + } + }; // Adapt the workflow-runtime `StepInvoker` shape onto the host's // `ChildStepInvoker` shape. The host's `onEvent` is the child's diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index b872f35c4..ee11d0f7a 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -163,18 +163,6 @@ export interface SidecarStepBuildEnvDeps { * need no cross-run conversation durability. */ durableConversation?: DurableConversationRegistry; - /** - * Build a TOOLLESS env: skip tool materialization entirely and attach an - * empty tool runtime. Set for an onTrigger body step. A body agent is - * guaranteed toolless by the deploy-time guard (a tool-bearing body agent - * is rejected at deploy), and -- critically -- the body child runs under - * the PARENT deployment's `mailboxAddress`/`stepCount`, so resolving a - * body step's deploy tree through `stepDeployTreeDir` would read the - * PARENT step's tools for a body stepId that happens to collide with a - * parent step id. Skipping materialization makes the toolless-body - * invariant structural rather than incidental on non-collision. - */ - toolless: boolean; } /** @@ -291,22 +279,23 @@ export function createSidecarStepBuildEnv( // case); a present-but-broken manifest surfaces loudly through // `materializeStepTools` rather than degrading to empty tools. // - // A toolless body step skips this entirely (empty tools), so a body - // stepId that collides with a parent step id can never read the parent's - // deploy tree; the body agent is guaranteed toolless by the deploy - // guard, so there is nothing to materialize. - const materialization: StepToolMaterialization = - deps.toolless === true - ? { factories: [], pluginFactories: [] } - : await materializeStepTools({ - dataDir: deps.dataDir, - mailboxAddress: deps.mailboxAddress, - stepId, - stepCount: deps.stepCount, - storeDir, - cache: deps.cache, - registries: deps.registries, - }); + // An onTrigger body step (CL-6448) materializes through the same call: + // for the single-step section deployment the head/step collapse in + // `stepDeployTreeDir` reads the deployment's own staged manifest -- + // exactly the body agent's pins the folded launch staged -- and a + // deployment that staged no tree for the body's stepId reads ENOENT + // into the legitimate empty-tools case. + const materialization: StepToolMaterialization = await materializeStepTools( + { + dataDir: deps.dataDir, + mailboxAddress: deps.mailboxAddress, + stepId, + stepCount: deps.stepCount, + storeDir, + cache: deps.cache, + registries: deps.registries, + }, + ); // Supervisor-backed transport for the step agent's mail tools // (OUTBOUND half of mailbox ownership). Inbound is inert -- the @@ -411,9 +400,6 @@ export function createSidecarStepBuildEnv( // Carry this step's live credential wiring the same way, so the // tool-bearing `agentFactory` can shape a consumer-scoped // `credentials` capability for any tool package that declares one. - // Omitted for a toolless body step's cold env builder (`toolless: - // true` callers pass no `credentialWiring`) -- a body agent is - // guaranteed toolless, so it has no tool to hand a credential to. if (credentialWiring !== undefined) { attachStepCredentials(env, { wiring: credentialWiring, stepId }); } diff --git a/vendor/intx/workflow-host/src/adapters/spawn-child.ts b/vendor/intx/workflow-host/src/adapters/spawn-child.ts index 131095885..547d9fc98 100644 --- a/vendor/intx/workflow-host/src/adapters/spawn-child.ts +++ b/vendor/intx/workflow-host/src/adapters/spawn-child.ts @@ -65,10 +65,13 @@ import type { SpawnChildWorkflow, SpawnSuspendableChild, SuspendableChildHandle, + WorkflowAuthorizeFn, WorkflowDefinition, WorkflowEvent, } from "@intx/workflow"; +import type { CredentialWiring } from "../child/run-child"; + /** * The terminal-status shape the runtime body expects back from a * spawn. Mirrored from `SpawnChildWorkflow`'s return type so the @@ -180,6 +183,21 @@ export type RunSuspendableChild = ( parentStepId: string; signal: AbortSignal; resumeFromEvents?: readonly WorkflowEvent[]; + /** + * The parent child's credentials-backed workflow authorize (CL-6448). + * A body agent's tool calls gate through the SAME per-step grant + * snapshot the top-level step invoker consults; without this the + * body env's throwing authorize stub blocks every tool invocation a + * body agent makes. Optional so a host that runs bodies toolless + * keeps its stub. + */ + authorize?: WorkflowAuthorizeFn; + /** + * The parent child's live credential wiring (CL-6448), so a body + * step's tool bundles can shape consumer-scoped `credentials` + * capabilities exactly as a top-level step's do. + */ + credentialWiring?: CredentialWiring; }, /** * Live inference-event sink for the child's agent steps. Threaded from the @@ -220,6 +238,9 @@ export type HostSpawnSuspendableChild = ( export function createInMemorySpawnSuspendableChild(opts: { bodies: ReadonlyMap; runSuspendableChild: RunSuspendableChild; + /** Threaded through verbatim to every spawn's input (CL-6448). */ + authorize?: WorkflowAuthorizeFn; + credentialWiring?: CredentialWiring; }): HostSpawnSuspendableChild { return async ( { @@ -260,6 +281,10 @@ export function createInMemorySpawnSuspendableChild(opts: { parentStepId, signal, ...(resumeFromEvents !== undefined ? { resumeFromEvents } : {}), + ...(opts.authorize !== undefined ? { authorize: opts.authorize } : {}), + ...(opts.credentialWiring !== undefined + ? { credentialWiring: opts.credentialWiring } + : {}), }, onEvent, ); diff --git a/vendor/intx/workflow-host/src/child/run-child.ts b/vendor/intx/workflow-host/src/child/run-child.ts index 500d02580..c1939c592 100644 --- a/vendor/intx/workflow-host/src/child/run-child.ts +++ b/vendor/intx/workflow-host/src/child/run-child.ts @@ -227,7 +227,7 @@ export function createCredentialsBackedAuthorize( // iteration shares the base step's grants. `baseStepId` is the identity // on an unscoped id, so a plain step is unaffected. const lookupStepId = baseStepId(stepId); - const entry = snapshot.steps.find((s) => s.stepId === lookupStepId); + const entry = findStepGrantsEntry(snapshot.steps, lookupStepId); if (entry === undefined) { const scopedNote = lookupStepId === stepId @@ -248,6 +248,26 @@ export function createCredentialsBackedAuthorize( }; } +/** + * Resolve a step's grants entry from the credentials snapshot, with the + * head collapse for onTrigger body steps (CL-6448): the snapshot is + * keyed by the PARENT deployment's stepOrder, so a body step's own id + * (`reply`) never appears in it. For a single-step deployment the sole + * entry IS the deployment's grant set — the same head/step collapse + * `resolveStepAddress` applies when the body's tools materialize from + * the head deploy tree — so a missed lookup resolves to that sole + * entry. A multi-step deployment gets no collapse: an unknown stepId + * against several entries is ambiguous and stays a miss. + */ +function findStepGrantsEntry( + steps: readonly T[], + lookupStepId: string, +): T | undefined { + const exact = steps.find((step) => step.stepId === lookupStepId); + if (exact !== undefined) return exact; + return steps.length === 1 ? steps[0] : undefined; +} + /** * The workflow-host child's drain controller is the production * implementation defined in `../drain-controller.ts`. The control-loop @@ -608,9 +628,7 @@ export async function runWorkflowChild( `workflow-child credential wiring: no credentials snapshot for step ${stepId}; a tool-bearing step cannot resolve its grants before the run carries any`, ); } - const entry = snapshot.steps.find( - (step) => step.stepId === baseStepId(stepId), - ); + const entry = findStepGrantsEntry(snapshot.steps, baseStepId(stepId)); if (entry === undefined) { throw new Error( `workflow-child credential wiring: credentials snapshot has no entry for step ${baseStepId(stepId)}`, @@ -717,6 +735,11 @@ export async function runWorkflowChild( // fail loud at startup rather than silently falling back to a disk read (the // exact behaviour this arm exists to avoid). A deployment with no onTrigger // body leaves the host undefined; its suspendable-child slot is never invoked. + const authorize = createCredentialsBackedAuthorize( + credentialsRef, + opts.bindings.evaluateGrants, + ); + let suspendableChildHost: HostSpawnSuspendableChild | undefined; if (bodiesMap.size > 0) { const executor = opts.bindings.runSuspendableChild; @@ -727,9 +750,15 @@ export async function runWorkflowChild( "bodies in-memory", ); } + // CL-6448: thread the parent's credentials-backed authorize and live + // credential wiring into every body spawn, so a body agent's tool + // calls gate through the same per-step grant snapshot (and its tool + // bundles resolve credentials) exactly as a top-level step's do. suspendableChildHost = createInMemorySpawnSuspendableChild({ bodies: bodiesMap, runSuspendableChild: executor, + authorize, + credentialWiring, }); } @@ -768,11 +797,6 @@ export async function runWorkflowChild( }; } - const authorize = createCredentialsBackedAuthorize( - credentialsRef, - opts.bindings.evaluateGrants, - ); - const drainController = createWorkflowHostDrainController({ definition }); // Warm-agent cache (design §3b). Built only when the deployment is a From 4eab242d1a72610d23f1ab1aa17bd1142f1e7058 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 17:36:00 -0700 Subject: [PATCH 3/3] CL-6448: record workflow-host body-spawn authorize delta Document the vendored authorize/credentialWiring seam and refresh the workflow-host kill-date hash. --- VENDORED.md | 13 +++++++++++-- scripts/checks/kill-dates.txt | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index 3aab4ecd3..ac4a18141 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -30,7 +30,7 @@ never a convenience. | `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the usage forward (CL-5879), pack-acceptance fixes, adopted deploy front, wire-projection writer, event-collector serialization, or anchor ordering | sawyer | 2026-09-19 | `check:killdates` | | `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `onBodyFailure` trigger policy and its projection (CL-6326, CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` | | `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | Carries no delta of its own, but must bind against the vendored `@intx/workflow` (whose `onBodyFailure` field flows through the projection it hashes); retired with the workflow delta | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164) or the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164), the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435), or the body-spawn authorize/credential threading (CL-6448); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | The pinned commit `b5580a02` is upstream's `v0.3.0` release tag, 16 commits past the previous pin `4ed8baf4`: a workflow-host supervisor @@ -115,7 +115,16 @@ defaulting to the fail-closed empty registries; `buildRuntimeEnv` wires `effects`, `invokeAction`, `loopFns`, and `runLoopIteration` into every run's env and is exported so a host's runtime-env-level probe (`apps/sidecar/test/action-runtime-env.test.ts`) can exercise the bind -without the full control-channel harness. `vendor/intx/workflow` (CL-6326, CL-6324) gives +without the full control-channel harness. `vendor/intx/workflow-host` +(CL-6448) also threads the parent child's credentials-backed authorize and +live `CredentialWiring` through the suspendable-child (onTrigger body) spawn +seam: `RunSuspendableChild`'s input and +`createInMemorySpawnSuspendableChild`'s opts gain optional +`authorize`/`credentialWiring` fields, and `run-child.ts` passes both when +building the body resolver, so a body agent's tool calls gate through the +same per-step grant snapshot a top-level step's do instead of the host's +throwing authorize stub. Upstream never runs tool-bearing body agents, so +the seam has no upstream analog yet. `vendor/intx/workflow` (CL-6326, CL-6324) gives `onTrigger` an `onBodyFailure?: "end" | "continue"` policy: absent or `"end"` preserves terminal-is-final, while `"continue"` lets a long-lived section re-arm past a `failed` body occurrence instead of one bad turn permanently diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index f582329a1..5d5db193e 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -19,7 +19,7 @@ vendor/intx/hub-api | sawyer | 2026-09-19 | f60df0650a87529068450abd6b1d482439cd vendor/intx/hub-sessions | sawyer | 2026-09-19 | 4898613ce9d77771a6207bf82c6ea5084537df7afc1890ec4343db377ecbb48c vendor/intx/workflow | sawyer | 2026-09-19 | 34628e7bbd0587f131a07e3a206141983881106963a20ab607e68aeed1135593 vendor/intx/workflow-deploy | sawyer | 2026-09-19 | 95711adf282180852b0daec1cac39d00a4dc24aff15f9a515e07eb3d2ca749f9 -vendor/intx/workflow-host | sawyer | 2026-09-19 | 4daab41ed006ce9e8b54533583d5863fca3b0d03159684b572e4a661f5b847ab +vendor/intx/workflow-host | sawyer | 2026-09-19 | 6e6717e784cc55035a595320b2b8e6ea01b49ac42d4c77b444a0dc59e354b8d0 packages/folded-runs | sawyer | 2026-11-01