From b266c43093227145f313fd133978d835cd5d1a90 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:03:21 -0700 Subject: [PATCH 01/14] fix(sdk): route hosted babysitter through canonical run Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- packages/sdk/src/cli.ts | 31 ++- packages/sdk/src/cli/direct-run.ts | 75 +++++- ...re-garden-babysitter-canonical-run.test.ts | 241 ++++++++++++++++++ 3 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index e5be59c1..f5659898 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -30,7 +30,10 @@ import { import { answerFlow } from './cli/answer.js'; import { checkAuthoredTriggers } from './cli/check-triggers.js'; import { parseWebhookArgs, runServeWebhook } from './cli/serve-webhook.js'; -import { runDirectFlow } from './cli/direct-run.js'; +import { + runDirectFlow, + type HostedSoftwareGardenRunOptions, +} from './cli/direct-run.js'; import { parseReplayArgs, replayJournal, type ReplayArgs } from './cli/replay.js'; import { parseStatusArgs, runStatus, type StatusArgs } from './cli/status.js'; import { @@ -182,6 +185,13 @@ export interface RunCliOptions { * SIGINT/SIGTERM are handled here, for the duration of that verb only. */ signal?: AbortSignal; + + /** + * Verified delivery authority and the only capability exposed to the + * canonical hosted Software Garden + Babysitter run. The standalone binary + * never constructs this option; an owning hosted action must inject it. + */ + hostedSoftwareGardenBabysitter?: HostedSoftwareGardenRunOptions; } /** @@ -213,11 +223,13 @@ export async function runCli( io: CliIo = PROCESS_IO, options: RunCliOptions = {}, ): Promise { - if (args.length === 1 && (args[0] === '--version' || args[0] === '-V')) { + if (options.hostedSoftwareGardenBabysitter === undefined + && args.length === 1 && (args[0] === '--version' || args[0] === '-V')) { io.stdout(options.version ?? packageVersion()); return 0; } - if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { + if (options.hostedSoftwareGardenBabysitter === undefined + && args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { io.stdout(USAGE); return 0; } @@ -229,6 +241,16 @@ export async function runCli( return 2; } + if (options.hostedSoftwareGardenBabysitter !== undefined + && (parsed.command !== 'run' || !isAuthoredFlowPath(parsed.value))) { + const report = inputFailureReport({ + kind: 'invalid_invocation', + message: 'Hosted Software Garden authority is accepted only by an authored flow run.', + }, 'value' in parsed && typeof parsed.value === 'string' ? parsed.value : undefined); + emitCheckReport(report, 'json' in parsed && parsed.json === true, io); + return 2; + } + if (parsed.command === 'add') return addPlugin(parsed.value, io); if (parsed.command === 'plugin') return runPluginCommand(parsed, io); @@ -389,6 +411,9 @@ export async function runCli( elapsedMs: now - startedSteps.get(progress.stepId)! }); }, daemon: { spawn: parsed.spawn && spawnAllowedByEnv() }, + ...(options.hostedSoftwareGardenBabysitter === undefined ? {} : { + hostedSoftwareGardenBabysitter: options.hostedSoftwareGardenBabysitter, + }), }; const execution = parsed.command === 'run' ? isAuthoredFlowPath(parsed.value) diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index 2d1fcaf0..33f2ba40 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -13,7 +13,12 @@ import { executeDurableAuthoredFlow } from '../authored-root.js'; import { AuthoredHumanParked } from '../authored-flow-error.js'; import { AuthoredFlowLoadError } from '../authored-flow-loader.js'; import { DirectInputError, parseDirectInput } from '../direct-input.js'; +import { + runHostedSoftwareGardenBabysitter, + type RunHostedSoftwareGardenBabysitterOptions, +} from '../hosted-extension-isolation.js'; import { JournalClient } from '../journal-client.js'; +import { PluginError } from '../plugin-manifest.js'; import { inputFailureReport } from './check.js'; import { checkAuthoredTriggers } from './check-triggers.js'; import { authoredInput, authoredWorkerRemedy, localAgentRemedy } from './local-agent-remedy.js'; @@ -31,11 +36,25 @@ import { type RunReport, } from './run.js'; +export type HostedSoftwareGardenRunOptions = Omit< + RunHostedSoftwareGardenBabysitterOptions, + 'flowPath' | 'input' +>; + +export interface RunDirectFlowOptions extends RunLifecycleOptions { + /** + * Host-verified authority for the canonical Software Garden + Babysitter + * delivery path. This is deliberately an in-process option: neither flow + * input nor CLI flags can mint the branded dispatch or queue capability. + */ + hostedSoftwareGardenBabysitter?: HostedSoftwareGardenRunOptions; +} + export async function runDirectFlow( path: string, inputArgument: string | undefined, dataDir: string, - options: RunLifecycleOptions = {}, + options: RunDirectFlowOptions = {}, ): Promise { let input: unknown; try { @@ -51,6 +70,60 @@ export async function runDirectFlow( }; } + // A hosted Software Garden delivery is still a normal authored `run`, but + // it must branch before the ordinary trigger checker imports tenant code or + // a daemon is attached. The caller supplies only authority that was minted + // from its verified delivery and its exact queue capability; the loader + // independently resolves the reviewed base plus installed, lock-backed + // Babysitter generation and the sandbox selects the exact matched handler. + if (options.hostedSoftwareGardenBabysitter !== undefined) { + const base: RunReport = { ...emptyReport('run'), path }; + try { + const result = await runHostedSoftwareGardenBabysitter({ + ...options.hostedSoftwareGardenBabysitter, + flowPath: path, + input, + }); + return { + exitCode: 0, + report: { + ...base, + ok: true, + status: 'completed', + completionReason: result.completionReason, + completedSteps: result.capabilityCalls, + }, + }; + } catch (error) { + if (error instanceof PluginError) { + return { + exitCode: 2, + report: { + ...base, + diagnostics: [...base.diagnostics, { + severity: 'refusal', + kind: error.code, + message: error.message, + }], + }, + }; + } + return { + exitCode: 1, + report: { + ...base, + status: 'failed', + completionReason: 'step_failed', + diagnostics: [...base.diagnostics, { + severity: 'failure', + kind: 'step_failed', + message: error instanceof Error ? error.message : 'Hosted Babysitter capability failed.', + }], + }, + }; + } + } + // Declared triggers are knowable before any daemon or step is started. // Importing the authored module is unavoidable here — trigger sources // are only observable after `flow(...).on(webhook(...))` has run — but diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts new file mode 100644 index 00000000..3e625f36 --- /dev/null +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -0,0 +1,241 @@ +import { + appendFileSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { runCli, type RunCliOptions } from '../src/cli.js'; +import { addExtensionPlugin } from '../src/cli/add-extension.js'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; +import { entriesFromDirectory, fakeGithub } from './fake-github.js'; + +const NATIVE_SHA = '8b33ebab8347514f80d9da5a81206a087f641714'; +const REF = `github:AgentWorkforce/flows@${NATIVE_SHA}#extensions/babysitter`; +const DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; +const MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; +const entries = entriesFromDirectory(resolve('../..', 'extensions/babysitter'), 'extensions/babysitter'); +const versions = { sdk: '2.0.33', surface: '2.0.33' }; +const roots: string[] = []; + +afterAll(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +function github() { + return fakeGithub({ 'AgentWorkforce/flows': { refs: {}, commits: { [NATIVE_SHA]: { entries } } } }); +} + +async function project(install = true) { + const root = mkdtempSync(join(tmpdir(), 'software-garden-canonical-run-')); + roots.push(root); + const flowPath = join(root, 'software-factory.flow.ts'); + writeFileSync(flowPath, readFileSync(resolve('../..', 'examples/software-factory/software-factory.flow.ts'))); + writeFileSync(join(root, 'flows.json'), JSON.stringify({ cli: 'codex', executors: ['github'] })); + if (install) { + const io = { stdout: () => {}, stderr: (message: string) => { throw new Error(message); } }; + expect(await addExtensionPlugin(REF, io, { + cwd: root, + fetch: github().fetch, + now: () => new Date('2026-09-28T00:00:00Z'), + versions, + })).toBe(0); + } else { + writeFileSync(join(root, 'flows.lock.json'), JSON.stringify({ version: 2, plugins: [] })); + } + return { root, flowPath }; +} + +function input(eventType = 'pull_request.labeled', deliveryId = 'delivery-canonical') { + return { + event: { provider: 'github', eventType, deliveryId }, + pullRequest: { + host: 'github', + owner: 'AgentWorkforce', + repo: 'flows', + number: 584, + headSha: 'a'.repeat(40), + }, + }; +} + +function hosted( + eventType: string, + deliveryId: string, + queue: NonNullable['babysitterTurn']['queue'], +): NonNullable { + return { + dispatch: hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', + eventType, + deliveryId, + }), + babysitterTurn: { queue }, + }; +} + +async function run( + flowPath: string, + descriptor: unknown, + authority: NonNullable, +) { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCode = await runCli([ + 'run', + flowPath, + '--input', + JSON.stringify(descriptor), + '--json', + '--no-observer-link', + ], { + stdout: line => stdout.push(line), + stderr: line => stderr.push(line), + }, { hostedSoftwareGardenBabysitter: authority }); + return { + exitCode, + report: JSON.parse(stdout.at(-1) ?? '{}') as Record, + stderr, + }; +} + +describe('canonical run dispatches the installed Software Garden Babysitter', () => { + it('refuses hosted authority on any command surface other than an authored run', async () => { + const stdout: string[] = []; + const exitCode = await runCli(['run', 'software-factory.flow.yaml', '--json'], { + stdout: line => stdout.push(line), + stderr: () => {}, + }, { + hostedSoftwareGardenBabysitter: hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => ({ receiptId: 'never', status: 'queued' }), + ), + }); + expect(exitCode).toBe(2); + expect(JSON.parse(stdout.at(-1) ?? '{}')).toMatchObject({ + ok: false, + path: 'software-factory.flow.yaml', + diagnostics: [expect.objectContaining({ + severity: 'refusal', + kind: 'invalid_invocation', + })], + }); + }); + + it('records one deterministic, exact reviewed plugin inventory member', async () => { + const installed = await project(); + const lock = JSON.parse(readFileSync(join(installed.root, 'flows.lock.json'), 'utf8')) as { + plugins: unknown[]; + }; + expect(lock.plugins).toEqual([{ + name: 'babysitter', + version: '0.2.0', + kind: 'flow-extension', + source: { + host: 'github', + owner: 'AgentWorkforce', + repo: 'flows', + sha: NATIVE_SHA, + path: 'extensions/babysitter', + }, + digest: DIGEST, + manifestSha256: MANIFEST_SHA256, + resolvedAt: '2026-09-28T00:00:00.000Z', + order: 1, + }]); + }); + + it.runIf(process.platform === 'linux')( + 'refuses a canonical hosted run with no installed Babysitter before the capability is called', async () => { + const empty = await project(false); + let calls = 0; + const result = await run(empty.flowPath, input(), hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, + )); + expect(result).toMatchObject({ + exitCode: 2, + report: { ok: false, command: 'run', path: empty.flowPath }, + }); + expect(result.report.diagnostics).toEqual([ + expect.objectContaining({ severity: 'refusal', kind: 'plugin_event_unroutable' }), + ]); + expect(calls).toBe(0); + }, + ); + + it.runIf(process.platform === 'linux')( + 'refuses installed-store drift before the canonical run calls the capability', async () => { + const changed = await project(); + appendFileSync(join(changed.root, '.flows/plugins', `babysitter@sha256:${DIGEST}`, 'turn.ts'), '\n// drift\n'); + let calls = 0; + const result = await run(changed.flowPath, input(), hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, + )); + expect(result).toMatchObject({ exitCode: 2, report: { ok: false } }); + expect(result.report.diagnostics).toEqual([ + expect.objectContaining({ severity: 'refusal', kind: 'plugin_source_drift' }), + ]); + expect(calls).toBe(0); + }, + ); + + it.runIf(process.platform === 'linux')( + 'refuses an unmatched verified route before the canonical run calls the capability', async () => { + const installed = await project(); + let calls = 0; + const result = await run(installed.flowPath, input('push'), hosted( + 'push', + 'delivery-canonical', + async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, + )); + expect(result).toMatchObject({ exitCode: 2, report: { ok: false } }); + expect(result.report.diagnostics).toEqual([ + expect.objectContaining({ severity: 'refusal', kind: 'plugin_event_unroutable' }), + ]); + expect(calls).toBe(0); + }, + ); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'runs the exact matched installed handler through the sandbox from the normal run command', async () => { + const installed = await project(); + const deliveryId = 'delivery-canonical-e2e'; + const authority = hosted('pull_request.labeled', deliveryId, async (request, received) => { + expect(received.dispatch).toBe(authority.dispatch); + expect(received.extension).toEqual({ + name: 'babysitter', + version: '0.2.0', + ref: REF, + digest: DIGEST, + }); + expect(request).toEqual({ delivery: { + deliveryId, + provider: 'github', + eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 584 }, + } }); + return { receiptId: `bst_${'1'.repeat(64)}`, status: 'queued' }; + }); + + await expect(run(installed.flowPath, input('pull_request.labeled', deliveryId), authority)).resolves.toMatchObject({ + exitCode: 0, + report: { + ok: true, + command: 'run', + path: installed.flowPath, + status: 'completed', + completionReason: 'success', + completedSteps: 1, + diagnostics: [], + }, + }); + }, + ); +}); From 3d6ff281ff75107435be714fca6fabc51d4ac6e6 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:12:36 -0700 Subject: [PATCH 02/14] docs: define canonical hosted Garden run contract Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- docs/BABYSITTER-CATALOG-HANDOFF.md | 43 ++++++++++++++++++------------ 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 414493cb..9713484c 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -13,12 +13,17 @@ The reviewed native artifact shipped in Flows 2.0.26. The existing `examples/babysitter` declares Claude, GitHub comment writes, and a merge-gate hook; it is not the native existing-session package. Ordinary authored dispatch still refuses matched extension handlers with `plugin_unsupported`, because it -imports tenant JavaScript in the host. The external exact-target Relay/Flows -runtime must instead call `runHostedSoftwareGardenBabysitter`, which captures -the reviewed Software Garden base and complete lock-backed installation as one -generation before it runs the exact matched native handler in the capability -sandbox. Keep enabled activation blocked with zero writes until that runtime -calls the entrypoint. +imports tenant JavaScript in the host. An owning exact-target Relay/Flows action +must instead use the normal embedded `runCli(["run", flowPath, "--input", ...])` +surface and inject `RunCliOptions.hostedSoftwareGardenBabysitter`. That +non-serializable option carries the host-verified dispatch and the single queue +capability; no CLI flag or flow input can mint either. The canonical authored +run branches before trigger inspection, tenant import, or daemon attachment, +then `runHostedSoftwareGardenBabysitter` captures the reviewed Software Garden +base and complete lock-backed installation as one generation before it runs the +exact matched native handler in the capability sandbox. Every other command or +path surface refuses the hosted authority. Keep enabled activation blocked with +zero writes until that action is released and deployed. The native handler must consume host-verified delivery authority, normalize the repository/PR event, and call the Cloud lineage path. Cloud must recheck the @@ -31,8 +36,9 @@ through the generic executor: #549 still refuses it. The SDK has a separate Linu capability sandbox that injects exactly `capabilities.cloud.babysitterTurn.queue` without exposing the base context, workspace, environment credentials, network, helpers, MCP, or harnesses. It is -reached by the canonical composition entrypoint, but no deployed runtime calls -that entrypoint and this must not be treated as enablement. The package's +reached by the canonical authored `run` surface when the owning hosted action +injects verified authority, but no deployed runtime consumes this contract and +this must not be treated as enablement. The package's `compat` requires the published 2.0.26 Surface/SDK release that routes `labeled`, `unlabeled`, and `ready_for_review`. Export it only from the reviewed release commit pinned below. The Software Factory flow's own independently @@ -59,11 +65,12 @@ normalized input against non-serializable verified dispatch authority, and permits one queue call. The parent capability adapter receives that original authority plus immutable extension provenance; the capability request never carries workspace, activation, listener, session, -lineage, label, head, prompt, merge, route, or config authority. Cloud PR #4002 -at `25412782bf148ff8dd8018bdbafd719d4e8347fa` deliberately supplies no execution +lineage, label, head, prompt, merge, route, or config authority. Merged Cloud +PR #4002 at merge commit `ced414ab40424c7bbd4cd780ad01751d7fc85685` +(reviewed head `6201470228b23c225290d0eee356eb1c0006e31d`) deliberately supplies no execution authority: it emits the exact-target `relay:hosted-flow-extension:v1` action for -an external Relay/Flows runtime. That runtime owns this entrypoint; only its -validated capability call reaches `relay:native-existing-session:v1` +an external Relay/Flows runtime. That runtime owns the embedded hosted-run +option; only its validated capability call reaches `relay:native-existing-session:v1` downstream. Merged Cloud PR #3942 owns that downstream lineage/authority core and must inject workspace, activation, and listener from persisted dispatch context, re-read live PR/label/head state, and return only `{ receiptId, status: @@ -80,11 +87,13 @@ the branded dispatch, and waits for the adapter's authoritative outcome before settling any premature child terminal frame. An authoritative adapter rejection settles immediately with its original typed error even if the child hangs. -Before replacing #549's refusal, the hosted caller must obtain an opaque base -and installation as one generation with `loadHostedExtensionRuntime`, then call -`runHostedCapabilityExtension` with both values. Every dispatch rechecks the -current extension declarations and complete project source tree against that -generation. Directory entries are streamed beneath a shared entry bound; +The normal embedded run deliberately does not replace #549's standalone +refusal. Its hosted option calls the canonical composition boundary, which +obtains an opaque base and installation as one generation with +`loadHostedExtensionRuntime`, then calls `runHostedCapabilityExtension` with +both values. Every dispatch rechecks the current extension declarations and +complete project source tree against that generation. Directory entries are +streamed beneath a shared entry bound; nonblocking no-follow descriptors and explicitly bounded reads enforce the cumulative-byte limit before source contents are buffered. The loader never imports tenant base code to derive authority. It From 644a00afef8fdfeb532721a35da7572e35218caa Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:23:03 -0700 Subject: [PATCH 03/14] test(sdk): tighten canonical babysitter evidence Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- ...re-garden-babysitter-canonical-run.test.ts | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index 3e625f36..ef38c1f9 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -150,56 +150,56 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () it.runIf(process.platform === 'linux')( 'refuses a canonical hosted run with no installed Babysitter before the capability is called', async () => { - const empty = await project(false); - let calls = 0; - const result = await run(empty.flowPath, input(), hosted( - 'pull_request.labeled', - 'delivery-canonical', - async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, - )); - expect(result).toMatchObject({ - exitCode: 2, - report: { ok: false, command: 'run', path: empty.flowPath }, - }); - expect(result.report.diagnostics).toEqual([ - expect.objectContaining({ severity: 'refusal', kind: 'plugin_event_unroutable' }), - ]); - expect(calls).toBe(0); + const empty = await project(false); + let calls = 0; + const result = await run(empty.flowPath, input(), hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, + )); + expect(result).toMatchObject({ + exitCode: 2, + report: { ok: false, command: 'run', path: empty.flowPath }, + }); + expect(result.report.diagnostics).toEqual([ + expect.objectContaining({ severity: 'refusal', kind: 'plugin_event_unroutable' }), + ]); + expect(calls).toBe(0); }, ); it.runIf(process.platform === 'linux')( 'refuses installed-store drift before the canonical run calls the capability', async () => { - const changed = await project(); - appendFileSync(join(changed.root, '.flows/plugins', `babysitter@sha256:${DIGEST}`, 'turn.ts'), '\n// drift\n'); - let calls = 0; - const result = await run(changed.flowPath, input(), hosted( - 'pull_request.labeled', - 'delivery-canonical', - async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, - )); - expect(result).toMatchObject({ exitCode: 2, report: { ok: false } }); - expect(result.report.diagnostics).toEqual([ - expect.objectContaining({ severity: 'refusal', kind: 'plugin_source_drift' }), - ]); - expect(calls).toBe(0); + const changed = await project(); + appendFileSync(join(changed.root, '.flows/plugins', `babysitter@sha256:${DIGEST}`, 'turn.ts'), '\n// drift\n'); + let calls = 0; + const result = await run(changed.flowPath, input(), hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, + )); + expect(result).toMatchObject({ exitCode: 2, report: { ok: false } }); + expect(result.report.diagnostics).toEqual([ + expect.objectContaining({ severity: 'refusal', kind: 'plugin_source_drift' }), + ]); + expect(calls).toBe(0); }, ); it.runIf(process.platform === 'linux')( 'refuses an unmatched verified route before the canonical run calls the capability', async () => { - const installed = await project(); - let calls = 0; - const result = await run(installed.flowPath, input('push'), hosted( - 'push', - 'delivery-canonical', - async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, - )); - expect(result).toMatchObject({ exitCode: 2, report: { ok: false } }); - expect(result.report.diagnostics).toEqual([ - expect.objectContaining({ severity: 'refusal', kind: 'plugin_event_unroutable' }), - ]); - expect(calls).toBe(0); + const installed = await project(); + let calls = 0; + const result = await run(installed.flowPath, input('push'), hosted( + 'push', + 'delivery-canonical', + async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; }, + )); + expect(result).toMatchObject({ exitCode: 2, report: { ok: false } }); + expect(result.report.diagnostics).toEqual([ + expect.objectContaining({ severity: 'refusal', kind: 'plugin_event_unroutable' }), + ]); + expect(calls).toBe(0); }, ); @@ -226,6 +226,7 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () await expect(run(installed.flowPath, input('pull_request.labeled', deliveryId), authority)).resolves.toMatchObject({ exitCode: 0, + stderr: [], report: { ok: true, command: 'run', From 2347863799528f1e060585682e038fed5c5805d9 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:26:35 -0700 Subject: [PATCH 04/14] fix(sdk): classify post-capability errors as failures Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- packages/sdk/src/cli/direct-run.ts | 14 +++-- ...re-garden-babysitter-canonical-run.test.ts | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index 33f2ba40..22f70208 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -76,11 +76,19 @@ export async function runDirectFlow( // from its verified delivery and its exact queue capability; the loader // independently resolves the reviewed base plus installed, lock-backed // Babysitter generation and the sandbox selects the exact matched handler. - if (options.hostedSoftwareGardenBabysitter !== undefined) { + const hostedSoftwareGarden = options.hostedSoftwareGardenBabysitter; + if (hostedSoftwareGarden !== undefined) { const base: RunReport = { ...emptyReport('run'), path }; + let capabilityInvoked = false; try { const result = await runHostedSoftwareGardenBabysitter({ - ...options.hostedSoftwareGardenBabysitter, + ...hostedSoftwareGarden, + babysitterTurn: { + queue: async (request, authority) => { + capabilityInvoked = true; + return await hostedSoftwareGarden.babysitterTurn.queue(request, authority); + }, + }, flowPath: path, input, }); @@ -95,7 +103,7 @@ export async function runDirectFlow( }, }; } catch (error) { - if (error instanceof PluginError) { + if (error instanceof PluginError && !capabilityInvoked) { return { exitCode: 2, report: { diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index ef38c1f9..a0598c7c 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -239,4 +239,56 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () }); }, ); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'reports capability denial once as a terminal failure without fallback', async () => { + const installed = await project(); + const refusal = new Error('live babysit label is absent'); + let calls = 0; + const result = await run(installed.flowPath, input(), hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => { calls += 1; throw refusal; }, + )); + expect(result).toMatchObject({ + exitCode: 1, + report: { + ok: false, + status: 'failed', + completionReason: 'step_failed', + diagnostics: [expect.objectContaining({ + severity: 'failure', + kind: 'step_failed', + message: refusal.message, + })], + }, + }); + expect(calls).toBe(1); + }, + ); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'does not misclassify a post-capability receipt error as a clean refusal', async () => { + const installed = await project(); + let calls = 0; + const result = await run(installed.flowPath, input(), hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => { calls += 1; return { receiptId: '', status: 'queued' }; }, + )); + expect(result).toMatchObject({ + exitCode: 1, + report: { + ok: false, + status: 'failed', + completionReason: 'step_failed', + diagnostics: [expect.objectContaining({ + severity: 'failure', + kind: 'step_failed', + })], + }, + }); + expect(calls).toBe(1); + }, + ); }); From a2bfb2e2f36287b9e1c058d2ef29f78ffe1220e2 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:45:15 -0700 Subject: [PATCH 05/14] fix(sdk): journal hosted babysitter runs Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- docs/BABYSITTER-CATALOG-HANDOFF.md | 20 +- packages/sdk/src/cli.ts | 15 + packages/sdk/src/cli/direct-run.ts | 67 +--- .../sdk/src/cli/hosted-software-garden-run.ts | 310 ++++++++++++++++++ ...re-garden-babysitter-canonical-run.test.ts | 56 +++- .../type-tests/hosted-software-garden-run.ts | 11 + 6 files changed, 409 insertions(+), 70 deletions(-) create mode 100644 packages/sdk/src/cli/hosted-software-garden-run.ts create mode 100644 packages/sdk/type-tests/hosted-software-garden-run.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 9713484c..1e8720cf 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -18,12 +18,15 @@ must instead use the normal embedded `runCli(["run", flowPath, "--input", ...])` surface and inject `RunCliOptions.hostedSoftwareGardenBabysitter`. That non-serializable option carries the host-verified dispatch and the single queue capability; no CLI flag or flow input can mint either. The canonical authored -run branches before trigger inspection, tenant import, or daemon attachment, -then `runHostedSoftwareGardenBabysitter` captures the reviewed Software Garden -base and complete lock-backed installation as one generation before it runs the -exact matched native handler in the capability sandbox. Every other command or -path surface refuses the hosted authority. Keep enabled activation blocked with -zero writes until that action is released and deployed. +run preflights the reviewed Software Garden base and complete lock-backed +installation as one generation before trigger inspection, tenant import, or +daemon attachment. It then admits one journaled effect step under a delivery- +and-pin-bound key and runs the exact matched native handler in the capability +sandbox. The queue write uses the journal's record/perform/confirm protocol; +the returned run ID, terminal reason, and completed-step count come from that +journal rather than an in-memory synthetic result. Every other command or path +surface refuses the hosted authority. Keep enabled activation blocked with zero +writes until that action is released and deployed. The native handler must consume host-verified delivery authority, normalize the repository/PR event, and call the Cloud lineage path. Cloud must recheck the @@ -91,8 +94,9 @@ The normal embedded run deliberately does not replace #549's standalone refusal. Its hosted option calls the canonical composition boundary, which obtains an opaque base and installation as one generation with `loadHostedExtensionRuntime`, then calls `runHostedCapabilityExtension` with -both values. Every dispatch rechecks the current extension declarations and -complete project source tree against that generation. Directory entries are +both values from its journal-attached worker. Unsupported local-worker flags +are refused instead of ignored. Every dispatch rechecks the current extension +declarations and complete project source tree against that generation. Directory entries are streamed beneath a shared entry bound; nonblocking no-follow descriptors and explicitly bounded reads enforce the cumulative-byte limit before source contents are buffered. The loader never imports diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index f5659898..800065d6 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -250,6 +250,21 @@ export async function runCli( emitCheckReport(report, 'json' in parsed && parsed.json === true, io); return 2; } + if (options.hostedSoftwareGardenBabysitter !== undefined && parsed.command === 'run') { + const ignoredFlag = args.find(argument => [ + '--local-agent', + '--agent-capacity', + '--allow-human-influenced', + ].includes(argument)); + if (ignoredFlag !== undefined) { + const report = inputFailureReport({ + kind: 'invalid_invocation', + message: `${ignoredFlag} is not supported by a hosted Software Garden run.`, + }, parsed.value); + emitCheckReport(report, parsed.json, io); + return 2; + } + } if (parsed.command === 'add') return addPlugin(parsed.value, io); if (parsed.command === 'plugin') return runPluginCommand(parsed, io); diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index 22f70208..a08f9c77 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -13,14 +13,13 @@ import { executeDurableAuthoredFlow } from '../authored-root.js'; import { AuthoredHumanParked } from '../authored-flow-error.js'; import { AuthoredFlowLoadError } from '../authored-flow-loader.js'; import { DirectInputError, parseDirectInput } from '../direct-input.js'; -import { - runHostedSoftwareGardenBabysitter, - type RunHostedSoftwareGardenBabysitterOptions, -} from '../hosted-extension-isolation.js'; import { JournalClient } from '../journal-client.js'; -import { PluginError } from '../plugin-manifest.js'; import { inputFailureReport } from './check.js'; import { checkAuthoredTriggers } from './check-triggers.js'; +import { + runHostedSoftwareGardenFlow, + type HostedSoftwareGardenRunOptions, +} from './hosted-software-garden-run.js'; import { authoredInput, authoredWorkerRemedy, localAgentRemedy } from './local-agent-remedy.js'; import { authoredCompletion, @@ -36,10 +35,7 @@ import { type RunReport, } from './run.js'; -export type HostedSoftwareGardenRunOptions = Omit< - RunHostedSoftwareGardenBabysitterOptions, - 'flowPath' | 'input' ->; +export type { HostedSoftwareGardenRunOptions } from './hosted-software-garden-run.js'; export interface RunDirectFlowOptions extends RunLifecycleOptions { /** @@ -78,58 +74,7 @@ export async function runDirectFlow( // Babysitter generation and the sandbox selects the exact matched handler. const hostedSoftwareGarden = options.hostedSoftwareGardenBabysitter; if (hostedSoftwareGarden !== undefined) { - const base: RunReport = { ...emptyReport('run'), path }; - let capabilityInvoked = false; - try { - const result = await runHostedSoftwareGardenBabysitter({ - ...hostedSoftwareGarden, - babysitterTurn: { - queue: async (request, authority) => { - capabilityInvoked = true; - return await hostedSoftwareGarden.babysitterTurn.queue(request, authority); - }, - }, - flowPath: path, - input, - }); - return { - exitCode: 0, - report: { - ...base, - ok: true, - status: 'completed', - completionReason: result.completionReason, - completedSteps: result.capabilityCalls, - }, - }; - } catch (error) { - if (error instanceof PluginError && !capabilityInvoked) { - return { - exitCode: 2, - report: { - ...base, - diagnostics: [...base.diagnostics, { - severity: 'refusal', - kind: error.code, - message: error.message, - }], - }, - }; - } - return { - exitCode: 1, - report: { - ...base, - status: 'failed', - completionReason: 'step_failed', - diagnostics: [...base.diagnostics, { - severity: 'failure', - kind: 'step_failed', - message: error instanceof Error ? error.message : 'Hosted Babysitter capability failed.', - }], - }, - }; - } + return runHostedSoftwareGardenFlow(path, input, dataDir, hostedSoftwareGarden, options); } // Declared triggers are knowable before any daemon or step is started. diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts new file mode 100644 index 00000000..239d8fe1 --- /dev/null +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -0,0 +1,310 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { compileSpec, toKernelSpec } from '../compile.js'; +import { runtimeVersions } from '../flow-extension-compat.js'; +import { + hostedExtensionDispatchIdentity, + type HostedEventIdentity, +} from '../flow-extension-loader.js'; +import { + loadHostedExtensionRuntime, + runHostedCapabilityExtension, + selectHostedExtensionForRuntime, + type RunHostedSoftwareGardenBabysitterOptions, +} from '../hosted-extension-isolation.js'; +import { atomicJson, readHelperReceipt } from '../helper-storage.js'; +import { JournalClient } from '../journal-client.js'; +import { PluginError } from '../plugin-manifest.js'; +import type { RunOutcome, StepDispatchEvent } from '../protocol.js'; +import { SPEC_SCHEMA_VERSION } from '../spec.js'; +import { withWorkerLease } from '../worker-lease.js'; +import { + classifyOutcome, + connect, + emptyReport, + protocolFailure, + socketFor, + type RunExecution, + type RunLifecycleOptions, + type RunReport, +} from './run.js'; + +const STEP_ID = 'babysitter-turn'; +const SURFACE_PATH = '/cloud/babysitter-turn'; + +export type HostedSoftwareGardenRunOptions = Omit< + RunHostedSoftwareGardenBabysitterOptions, + 'flowPath' | 'input' | 'bubblewrapPath' | 'nodePath' | 'prlimitPath' +>; + +/** + * Execute the pinned hosted composition as one journaled effect step. Plugin, + * pin, route, base, and platform refusals are resolved before a run exists; + * every error after admission is a terminal step failure because the external + * queue outcome may already be in doubt. + */ +export async function runHostedSoftwareGardenFlow( + path: string, + input: unknown, + dataDir: string, + hosted: HostedSoftwareGardenRunOptions, + lifecycle: RunLifecycleOptions, +): Promise { + const base: RunReport = { ...emptyReport('run'), path }; + let identity: HostedEventIdentity; + let runtime: Awaited>; + let selected: Awaited>; + try { + identity = hostedExtensionDispatchIdentity(hosted.dispatch); + runtime = await loadHostedExtensionRuntime(path); + selected = await selectHostedExtensionForRuntime( + runtime.installation, + runtime.base, + identity, + runtimeVersions(), + ); + } catch (error) { + if (error instanceof PluginError) return pluginRefusal(base, error); + return hostedFailure(base, error); + } + + const socketPath = socketFor(dataDir); + const client = new JournalClient(socketPath); + const connected = await connect(client, 'run', dataDir, base, lifecycle); + if (connected !== undefined) return connected; + + const admissionIdentity = { + provider: identity.provider, + eventType: hosted.dispatch.eventType, + deliveryId: hosted.dispatch.deliveryId, + base: runtime.base, + extension: { + name: selected.manifest.name, + version: selected.manifest.version, + ref: selected.artifact.ref, + digest: selected.artifact.digest, + manifestSha256: selected.artifact.manifestSha256, + }, + }; + const admissionDigest = createHash('sha256') + .update(JSON.stringify(admissionIdentity)) + .digest('hex'); + const stream = `hosted-babysitter-${admissionDigest}`; + const instruction = JSON.stringify({ + type: 'effect', + provider: 'cloud', + verb: 'babysitter-turn', + identity, + authority: admissionIdentity, + input, + }); + const spec = toKernelSpec(compileSpec({ + version: SPEC_SCHEMA_VERSION, + name: 'software-factory/hosted-babysitter', + steps: [{ + id: STEP_ID, + type: 'agent', + instruction, + maxIterations: 1, + recoveryMode: 'reset', + surfaces: { + streams: [{ stream }], + external: [SURFACE_PATH], + }, + }], + })); + const peer = client.createPeer(); + let work: Promise | undefined; + let capabilityFailure: unknown; + let workerFailure: unknown; + + const dispatch = (event: StepDispatchEvent): void => { + const dispatched = event.spec as { instruction?: string; surfaces?: { streams?: { stream: string }[] } }; + if (work !== undefined || event.step_id !== STEP_ID || event.step_type !== 'agent' + || dispatched.instruction !== instruction + || !dispatched.surfaces?.streams?.some(pin => pin.stream === stream)) { + workerFailure = new Error('Hosted Babysitter worker received an unexpected dispatch.'); + peer.close(workerFailure); + return; + } + const attempt = completeHostedDispatch(peer, event, runtime, input, hosted, dataDir); + work = attempt; + void attempt.then(completion => { + capabilityFailure ??= completion.failure; + }, error => { + workerFailure ??= error; + }).finally(() => { + if (work === attempt) work = undefined; + }); + }; + + peer.on('step.dispatch', dispatch); + peer.on('error', error => { workerFailure ??= error; }); + if (lifecycle.onJournalEntry !== undefined) client.on('entry', lifecycle.onJournalEntry); + try { + await peer.connect(); + await peer.hello('flows-hosted-babysitter'); + const pins = { workspace: [], streams: [{ stream, read_offset: 0 }] }; + await peer.workerAttach(`hosted-babysitter-${randomUUID()}`, ['agent'], pins, 1, [stream]); + const admissionKey = `hosted-babysitter:${admissionDigest}`; + const started = await client.runStart(spec, undefined, admissionKey, lifecycle.onJournalEntry !== undefined); + lifecycle.onRunStarted?.({ runId: started.run_id, flow: path }); + // An idempotent concurrent start can observe the original admission while + // its leased attempt is still running. Resume is live-lease-aware and + // converts that snapshot into the same parked/terminal shape handled by + // every other CLI run before classification. + const outcome = started.status === 'running' + ? await client.runResume(started.run_id, true) + : started; + const execution = await classifyOutcome( + client, + 'run', + outcome, + base, + socketPath, + { ...lifecycle, dataDir }, + ); + if (execution.exitCode !== 1 || capabilityFailure === undefined) return execution; + return { + ...execution, + report: { + ...execution.report, + diagnostics: execution.report.diagnostics.map(diagnostic => diagnostic.kind === 'step_failed' + ? { ...diagnostic, message: errorMessage(capabilityFailure) } + : diagnostic), + }, + }; + } catch (error) { + return protocolFailure('run', base, socketPath, workerFailure ?? error); + } finally { + if (lifecycle.onJournalEntry !== undefined) client.off('entry', lifecycle.onJournalEntry); + peer.off('step.dispatch', dispatch); + peer.close(); + client.close(); + } +} + +interface HostedCompletion { + readonly outcome: RunOutcome; + readonly failure?: unknown; +} + +async function completeHostedDispatch( + peer: JournalClient, + dispatch: StepDispatchEvent, + runtime: Awaited>, + input: unknown, + hosted: HostedSoftwareGardenRunOptions, + dataDir: string, +): Promise { + let result: unknown; + let effectConfirmed = false; + let failure: unknown; + try { + result = await withWorkerLease(peer, dispatch, async signal => { + return await runHostedCapabilityExtension({ + installation: runtime.installation, + base: runtime.base, + dispatch: hosted.dispatch, + input, + ...(hosted.timeoutMs === undefined ? {} : { timeoutMs: hosted.timeoutMs }), + babysitterTurn: { + queue: async (request, authority) => { + const file = hostedReceiptPath(dataDir, dispatch); + let receipt: unknown; + const performed = await peer.performEffect({ + runId: dispatch.run_id, + stepId: dispatch.step_id, + attempt: dispatch.attempt, + idempotencyKey: dispatch.idempotency_key, + surfacePath: SURFACE_PATH, + revisionBefore: 'pending', + revisionAfter: hosted.dispatch.deliveryId, + }, async () => { + signal.throwIfAborted(); + receipt = await hosted.babysitterTurn.queue(request, authority); + await atomicJson(file, receipt); + signal.throwIfAborted(); + }); + if (!performed) receipt = await readHelperReceipt(file); + effectConfirmed = true; + return receipt; + }, + }, + }); + }); + } catch (error) { + failure = error; + } + + const output = failure === undefined + ? { type: 'hosted-flow-extension', result } + : { type: 'hosted-flow-extension', diagnostic: errorMessage(failure) }; + const outcome = await peer.stepComplete( + dispatch.run_id, + dispatch.step_id, + dispatch.attempt, + dispatch.idempotency_key, + failure === undefined ? 'success' : 'worker_error', + { + output, + started_pins: dispatch.pins, + end_pins: dispatch.pins, + ...(failure === undefined ? {} : { trajectory_tail: output }), + effects: effectConfirmed + ? [{ surface_path: SURFACE_PATH, idempotency_key: dispatch.idempotency_key }] + : [], + }, + ); + return failure === undefined ? { outcome } : { outcome, failure }; +} + +function hostedReceiptPath(dataDir: string, dispatch: StepDispatchEvent): string { + const name = createHash('sha256') + .update(`${dispatch.run_id}:${dispatch.step_id}:${dispatch.idempotency_key}`) + .digest('hex'); + return join(dataDir, 'hosted-extension-receipts', `${name}.json`); +} + +function pluginRefusal(base: RunReport, error: PluginError): RunExecution { + return { + exitCode: 2, + report: { + ...base, + diagnostics: [...base.diagnostics, { + severity: 'refusal', + kind: error.code, + message: error.message, + }], + }, + }; +} + +function hostedFailure( + base: RunReport, + error: unknown, + runId?: string, + socketPath?: string, + completedSteps?: number, +): RunExecution { + return { + exitCode: 1, + report: { + ...base, + ...(runId === undefined ? {} : { runId }), + ...(socketPath === undefined ? {} : { socketPath }), + status: 'failed', + completionReason: 'step_failed', + ...(completedSteps === undefined ? {} : { completedSteps }), + diagnostics: [...base.diagnostics, { + severity: 'failure', + kind: 'step_failed', + message: errorMessage(error), + }], + }, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Hosted Babysitter capability failed.'; +} diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index a0598c7c..6acf24a5 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -88,6 +88,8 @@ async function run( flowPath, '--input', JSON.stringify(descriptor), + '--data-dir', + join(flowPath, '..', 'daemon'), '--json', '--no-observer-link', ], { @@ -148,6 +150,36 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () }]); }); + it.each(['--local-agent', '--agent-capacity', '--allow-human-influenced'])( + 'refuses the unsupported %s flag instead of silently ignoring it', async (flag) => { + const installed = await project(); + const stdout: string[] = []; + const args = [ + 'run', installed.flowPath, '--input', JSON.stringify(input()), flag, + ...(flag === '--agent-capacity' ? ['2'] : []), '--json', '--no-observer-link', + ]; + const exitCode = await runCli(args, { + stdout: line => stdout.push(line), + stderr: () => {}, + }, { + hostedSoftwareGardenBabysitter: hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => ({ receiptId: 'never', status: 'queued' }), + ), + }); + expect(exitCode).toBe(2); + expect(JSON.parse(stdout.at(-1) ?? '{}')).toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ + severity: 'refusal', + kind: 'invalid_invocation', + message: expect.stringContaining(flag), + })], + }); + }, + ); + it.runIf(process.platform === 'linux')( 'refuses a canonical hosted run with no installed Babysitter before the capability is called', async () => { const empty = await project(false); @@ -207,7 +239,9 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () 'runs the exact matched installed handler through the sandbox from the normal run command', async () => { const installed = await project(); const deliveryId = 'delivery-canonical-e2e'; + let calls = 0; const authority = hosted('pull_request.labeled', deliveryId, async (request, received) => { + calls += 1; expect(received.dispatch).toBe(authority.dispatch); expect(received.extension).toEqual({ name: 'babysitter', @@ -224,19 +258,35 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () return { receiptId: `bst_${'1'.repeat(64)}`, status: 'queued' }; }); - await expect(run(installed.flowPath, input('pull_request.labeled', deliveryId), authority)).resolves.toMatchObject({ + const first = await run(installed.flowPath, input('pull_request.labeled', deliveryId), authority); + expect(first).toMatchObject({ exitCode: 0, stderr: [], report: { ok: true, command: 'run', path: installed.flowPath, + runId: expect.any(String), + socketPath: expect.any(String), status: 'completed', completionReason: 'success', completedSteps: 1, diagnostics: [], }, }); + const retried = await run(installed.flowPath, input('pull_request.labeled', deliveryId), authority); + expect(retried).toMatchObject({ + exitCode: 0, + stderr: [], + report: { + ok: true, + runId: first.report.runId, + status: 'completed', + completionReason: 'success', + completedSteps: 1, + }, + }); + expect(calls).toBe(1); }, ); @@ -254,6 +304,8 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () exitCode: 1, report: { ok: false, + runId: expect.any(String), + socketPath: expect.any(String), status: 'failed', completionReason: 'step_failed', diagnostics: [expect.objectContaining({ @@ -280,6 +332,8 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () exitCode: 1, report: { ok: false, + runId: expect.any(String), + socketPath: expect.any(String), status: 'failed', completionReason: 'step_failed', diagnostics: [expect.objectContaining({ diff --git a/packages/sdk/type-tests/hosted-software-garden-run.ts b/packages/sdk/type-tests/hosted-software-garden-run.ts new file mode 100644 index 00000000..917b9690 --- /dev/null +++ b/packages/sdk/type-tests/hosted-software-garden-run.ts @@ -0,0 +1,11 @@ +import type { RunCliOptions } from '../src/cli.js'; + +type Hosted = NonNullable; + +// Production callers can supply only the verified authority/capability pair +// and a timeout. Sandbox executable paths remain internal test seams. +const noBubblewrapOverride: 'bubblewrapPath' extends keyof Hosted ? true : false = false; +const noNodeOverride: 'nodePath' extends keyof Hosted ? true : false = false; +const noPrlimitOverride: 'prlimitPath' extends keyof Hosted ? true : false = false; + +void [noBubblewrapOverride, noNodeOverride, noPrlimitOverride]; From c4d3c4e2b0456df55ce57a95608315a0b1ad52a4 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:59:20 -0700 Subject: [PATCH 06/14] fix(sdk): exclude journal data from hosted source Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- .../sdk/src/cli/hosted-software-garden-run.ts | 2 +- packages/sdk/src/hosted-base-snapshot.ts | 5 ++++- packages/sdk/tests/hosted-base-snapshot.test.ts | 16 ++++++++++++++++ ...tware-garden-babysitter-canonical-run.test.ts | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index 239d8fe1..165e87a9 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -39,7 +39,7 @@ export type HostedSoftwareGardenRunOptions = Omit< /** * Execute the pinned hosted composition as one journaled effect step. Plugin, - * pin, route, base, and platform refusals are resolved before a run exists; + * pin, route, and base refusals are resolved before a run exists; * every error after admission is a terminal step failure because the external * queue outcome may already be in doubt. */ diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 284d7f4a..747f4add 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -27,7 +27,10 @@ import { } from './hosted-promise-safety.js'; import { PluginError } from './plugin-manifest.js'; -const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); +// Runtime journals are not authored base source. Keeping the standard data +// directory in the generation would make daemon startup invalidate the exact +// generation it was started to execute. +const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', '.relayflowd', 'node_modules']); const CHMOD = chmod; const MKDIR = mkdir; const MKDTEMP = mkdtemp; diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 9bc97584..1ea16b37 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -192,6 +192,22 @@ describe('hosted base private snapshot', () => { } }); + it('excludes the standard relayflowd data directory from the admitted generation', async () => { + const { project, flowPath } = fixture(); + const data = join(project, '.relayflowd'); + mkdirSync(join(data, 'runs'), { recursive: true }); + writeFileSync(join(data, 'relayflowd.sqlite3'), 'before'); + const snapshot = await createHostedBaseSnapshot(flowPath); + try { + expect(() => readFileSync(join(snapshot.snapshotRoot, '.relayflowd/relayflowd.sqlite3'))).toThrow(); + const before = await hostedBaseSourceDigest(snapshot.liveSources); + writeFileSync(join(data, 'runs/new.sqlite3'), 'after'); + expect(await hostedBaseSourceDigest(snapshot.liveSources)).toBe(before); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); + it('refuses an oversized source file before buffering its contents', async () => { const { project, flowPath } = fixture(); const oversized = join(project, 'oversized.bin'); diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index 6acf24a5..f16454c6 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -89,7 +89,7 @@ async function run( '--input', JSON.stringify(descriptor), '--data-dir', - join(flowPath, '..', 'daemon'), + join(flowPath, '..', '.relayflowd'), '--json', '--no-observer-link', ], { From 66df8decb14a80e59348fb637349a531d2c37d08 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:59:52 -0700 Subject: [PATCH 07/14] test(sdk): allow journal progress diagnostics Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- .../sdk/tests/software-garden-babysitter-canonical-run.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index f16454c6..54d0fcd5 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -261,7 +261,6 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () const first = await run(installed.flowPath, input('pull_request.labeled', deliveryId), authority); expect(first).toMatchObject({ exitCode: 0, - stderr: [], report: { ok: true, command: 'run', @@ -277,7 +276,6 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () const retried = await run(installed.flowPath, input('pull_request.labeled', deliveryId), authority); expect(retried).toMatchObject({ exitCode: 0, - stderr: [], report: { ok: true, runId: first.report.runId, From d5182bd13726a7a31f2b76e5c0765d71d7ec6400 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 13:16:26 -0700 Subject: [PATCH 08/14] fix(sdk): fail closed on empty capability errors Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- docs/BABYSITTER-CATALOG-HANDOFF.md | 10 ++++--- packages/sdk/src/cli.ts | 12 ++++---- .../sdk/src/cli/hosted-software-garden-run.ts | 29 +++++++++++-------- ...re-garden-babysitter-canonical-run.test.ts | 27 +++++++++++++++++ 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 1e8720cf..485edec4 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -96,11 +96,13 @@ obtains an opaque base and installation as one generation with `loadHostedExtensionRuntime`, then calls `runHostedCapabilityExtension` with both values from its journal-attached worker. Unsupported local-worker flags are refused instead of ignored. Every dispatch rechecks the current extension -declarations and complete project source tree against that generation. Directory entries are -streamed beneath a shared entry bound; +declarations and complete project source tree against that generation. +Directory entries are streamed beneath a shared entry bound; nonblocking no-follow descriptors and explicitly bounded reads enforce the -cumulative-byte limit before source contents are buffered. The loader never imports -tenant base code to derive authority. It +cumulative-byte limit before source contents are buffered. Only runtime/control +directories (`.flows`, `.git`, `.relayflowd`, and `node_modules`) are excluded, +so starting the standard journal daemon cannot invalidate the generation it is +executing. The loader never imports tenant base code to derive authority. It requires the exact reviewed Software Factory flow-file SHA-256 and assigns its pinned name/version in the parent; project `node_modules`, relative imports, stdout, process termination, globals, and module caches therefore cannot forge diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 800065d6..2888bc7e 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -251,11 +251,13 @@ export async function runCli( return 2; } if (options.hostedSoftwareGardenBabysitter !== undefined && parsed.command === 'run') { - const ignoredFlag = args.find(argument => [ - '--local-agent', - '--agent-capacity', - '--allow-human-influenced', - ].includes(argument)); + const ignoredFlag = parsed.localAgent + ? '--local-agent' + : parsed.agentCapacity !== undefined + ? '--agent-capacity' + : parsed.allowHumanInfluenced + ? '--allow-human-influenced' + : undefined; if (ignoredFlag !== undefined) { const report = inputFailureReport({ kind: 'invalid_invocation', diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index 165e87a9..636113ec 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -115,6 +115,7 @@ export async function runHostedSoftwareGardenFlow( })); const peer = client.createPeer(); let work: Promise | undefined; + let capabilityFailed = false; let capabilityFailure: unknown; let workerFailure: unknown; @@ -130,7 +131,10 @@ export async function runHostedSoftwareGardenFlow( const attempt = completeHostedDispatch(peer, event, runtime, input, hosted, dataDir); work = attempt; void attempt.then(completion => { - capabilityFailure ??= completion.failure; + if (completion.failed) { + capabilityFailed = true; + capabilityFailure = completion.failure; + } }, error => { workerFailure ??= error; }).finally(() => { @@ -164,7 +168,7 @@ export async function runHostedSoftwareGardenFlow( socketPath, { ...lifecycle, dataDir }, ); - if (execution.exitCode !== 1 || capabilityFailure === undefined) return execution; + if (execution.exitCode !== 1 || !capabilityFailed) return execution; return { ...execution, report: { @@ -184,10 +188,9 @@ export async function runHostedSoftwareGardenFlow( } } -interface HostedCompletion { - readonly outcome: RunOutcome; - readonly failure?: unknown; -} +type HostedCompletion = + | { readonly outcome: RunOutcome; readonly failed: false } + | { readonly outcome: RunOutcome; readonly failed: true; readonly failure: unknown }; async function completeHostedDispatch( peer: JournalClient, @@ -199,6 +202,7 @@ async function completeHostedDispatch( ): Promise { let result: unknown; let effectConfirmed = false; + let failed = false; let failure: unknown; try { result = await withWorkerLease(peer, dispatch, async signal => { @@ -234,29 +238,30 @@ async function completeHostedDispatch( }); }); } catch (error) { + failed = true; failure = error; } - const output = failure === undefined - ? { type: 'hosted-flow-extension', result } - : { type: 'hosted-flow-extension', diagnostic: errorMessage(failure) }; + const output = failed + ? { type: 'hosted-flow-extension', diagnostic: errorMessage(failure) } + : { type: 'hosted-flow-extension', result }; const outcome = await peer.stepComplete( dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.idempotency_key, - failure === undefined ? 'success' : 'worker_error', + failed ? 'worker_error' : 'success', { output, started_pins: dispatch.pins, end_pins: dispatch.pins, - ...(failure === undefined ? {} : { trajectory_tail: output }), + ...(failed ? { trajectory_tail: output } : {}), effects: effectConfirmed ? [{ surface_path: SURFACE_PATH, idempotency_key: dispatch.idempotency_key }] : [], }, ); - return failure === undefined ? { outcome } : { outcome, failure }; + return failed ? { outcome, failed: true, failure } : { outcome, failed: false }; } function hostedReceiptPath(dataDir: string, dispatch: StepDispatchEvent): string { diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index 54d0fcd5..8e1ff48f 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -343,4 +343,31 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () expect(calls).toBe(1); }, ); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'fails closed when the capability rejects without an Error value', async () => { + const installed = await project(); + let calls = 0; + const result = await run(installed.flowPath, input(), hosted( + 'pull_request.labeled', + 'delivery-canonical', + async () => { calls += 1; return await Promise.reject(undefined); }, + )); + expect(result).toMatchObject({ + exitCode: 1, + report: { + ok: false, + runId: expect.any(String), + status: 'failed', + completionReason: 'step_failed', + diagnostics: [expect.objectContaining({ + severity: 'failure', + kind: 'step_failed', + message: 'Hosted Babysitter capability failed.', + })], + }, + }); + expect(calls).toBe(1); + }, + ); }); From fa2e6d97623871397a47e15efe5c599a2ba80f5c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 13:27:43 -0700 Subject: [PATCH 09/14] fix(sdk): carry recorded hosted effects Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- packages/sdk/src/cli/hosted-software-garden-run.ts | 14 ++++++++++---- ...oftware-garden-babysitter-canonical-run.test.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index 636113ec..f0bb7fe0 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -201,7 +201,7 @@ async function completeHostedDispatch( dataDir: string, ): Promise { let result: unknown; - let effectConfirmed = false; + let effectRecorded = false; let failed = false; let failure: unknown; try { @@ -225,13 +225,19 @@ async function completeHostedDispatch( revisionBefore: 'pending', revisionAfter: hosted.dispatch.deliveryId, }, async () => { + // performEffect reaches this callback only after effect.record + // succeeded, so a provider rejection still has a journal fact + // the failing step completion must carry. + effectRecorded = true; signal.throwIfAborted(); receipt = await hosted.babysitterTurn.queue(request, authority); await atomicJson(file, receipt); signal.throwIfAborted(); }); - if (!performed) receipt = await readHelperReceipt(file); - effectConfirmed = true; + if (!performed) { + effectRecorded = true; + receipt = await readHelperReceipt(file); + } return receipt; }, }, @@ -256,7 +262,7 @@ async function completeHostedDispatch( started_pins: dispatch.pins, end_pins: dispatch.pins, ...(failed ? { trajectory_tail: output } : {}), - effects: effectConfirmed + effects: effectRecorded ? [{ surface_path: SURFACE_PATH, idempotency_key: dispatch.idempotency_key }] : [], }, diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index 8e1ff48f..665fde38 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -363,7 +363,7 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () diagnostics: [expect.objectContaining({ severity: 'failure', kind: 'step_failed', - message: 'Hosted Babysitter capability failed.', + message: expect.stringContaining('non-error value'), })], }, }); From 015fe9855bfd247f38c78398c55165d3947f7b05 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:26:03 -0700 Subject: [PATCH 10/14] fix(sdk): close hosted effect recovery gaps Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- .../sdk/src/cli/hosted-software-garden-run.ts | 23 +++- packages/sdk/src/hosted-extension-protocol.ts | 17 ++- ...re-garden-babysitter-canonical-run.test.ts | 112 +++++++++++++++++- 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index f0bb7fe0..135cf238 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -230,14 +230,25 @@ async function completeHostedDispatch( // the failing step completion must carry. effectRecorded = true; signal.throwIfAborted(); - receipt = await hosted.babysitterTurn.queue(request, authority); - await atomicJson(file, receipt); + // A crash after the durable receipt write but before + // effect.confirm leaves this election reclaimable. Consume that + // receipt on the reclaimed attempt instead of calling the + // external provider a second time. + try { + receipt = await readHelperReceipt(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + receipt = await hosted.babysitterTurn.queue(request, authority); + await atomicJson(file, receipt); + } signal.throwIfAborted(); }); - if (!performed) { - effectRecorded = true; - receipt = await readHelperReceipt(file); - } + // Also required after a confirmed election followed by a crash + // before step.complete. An elected recovery callback may already + // have populated receipt above, so this is deliberately based on + // the value rather than only on performEffect's return flag. + if (!performed) effectRecorded = true; + if (receipt === undefined) receipt = await readHelperReceipt(file); return receipt; }, }, diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index a468587d..0f5dc1d0 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -141,13 +141,24 @@ export async function exchangeHostedExtension( finish(capabilityError ?? error); }; const timeout = SET_TIMEOUT(() => { - CHILD_PROCESS_KILL(child, 'SIGKILL'); - finish(new PluginError( + const error = new PluginError( 'plugin_unsupported', capabilityState === 'pending' ? 'Hosted capability outcome is in doubt after the sandbox timeout.' : 'Hosted extension sandbox timed out.', - )); + ); + // The host capability is not cancellable at this boundary. Reporting a + // terminal result while it is still running would let the provider write + // land after the journal had already completed the step. Kill the tenant + // sandbox immediately, but defer the terminal protocol result until the + // in-flight capability has settled and its effect can be journaled. + if (capabilityState === 'pending') { + deferredProtocolError ??= error; + CHILD_PROCESS_KILL(child, 'SIGKILL'); + return; + } + CHILD_PROCESS_KILL(child, 'SIGKILL'); + finish(error); }, timeoutMs); TIMER_UNREF(timeout); diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index 665fde38..7a068010 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -3,15 +3,18 @@ import { existsSync, mkdtempSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { afterAll, describe, expect, it } from 'vitest'; +import { setTimeout as delay } from 'node:timers/promises'; +import { afterAll, describe, expect, it, vi } from 'vitest'; import { runCli, type RunCliOptions } from '../src/cli.js'; import { addExtensionPlugin } from '../src/cli/add-extension.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; +import { JournalClient } from '../src/journal-client.js'; import { entriesFromDirectory, fakeGithub } from './fake-github.js'; const NATIVE_SHA = '8b33ebab8347514f80d9da5a81206a087f641714'; @@ -288,6 +291,113 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () }, ); + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'does not complete the journal while a timed-out capability is still pending', async () => { + const installed = await project(); + const deliveryId = 'delivery-timeout-pending'; + let calls = 0; + let release!: () => void; + let capabilityStarted!: () => void; + const released = new Promise(resolveReleased => { release = resolveReleased; }); + const started = new Promise(resolveStarted => { capabilityStarted = resolveStarted; }); + const authority = { + ...hosted('pull_request.labeled', deliveryId, async () => { + calls += 1; + capabilityStarted(); + await released; + return { receiptId: `bst_${'2'.repeat(64)}`, status: 'queued' }; + }), + timeoutMs: 25, + }; + + let settled = false; + const pending = run(installed.flowPath, input('pull_request.labeled', deliveryId), authority); + void pending.then(() => { settled = true; }, () => { settled = true; }); + await started; + await delay(75); + expect(settled).toBe(false); + expect(calls).toBe(1); + + release(); + const result = await pending; + expect(result).toMatchObject({ + exitCode: 1, + report: { + ok: false, + status: 'failed', + completionReason: 'step_failed', + diagnostics: [expect.objectContaining({ + severity: 'failure', + kind: 'step_failed', + message: expect.stringContaining('outcome is in doubt'), + })], + }, + }); + const receipts = join(installed.root, '.relayflowd', 'hosted-extension-receipts'); + const afterCompletion = readdirSync(receipts).map(file => readFileSync(join(receipts, file), 'utf8')); + await delay(50); + expect(readdirSync(receipts).map(file => readFileSync(join(receipts, file), 'utf8'))).toEqual(afterCompletion); + expect(calls).toBe(1); + }, + ); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'reuses the durable receipt when an elected effect is replayed before confirmation', async () => { + const installed = await project(); + const deliveryId = 'delivery-reclaimed-receipt'; + let calls = 0; + const replay = vi.spyOn(JournalClient.prototype, 'performEffect').mockImplementationOnce( + async function (effect, perform) { + const { deduped } = await this.effectRecord( + effect.runId, + effect.stepId, + effect.attempt, + effect.idempotencyKey, + effect.surfacePath, + effect.revisionBefore, + effect.revisionAfter, + ); + expect(deduped).toBe(false); + await perform(); + // Inject the crash boundary: the provider receipt is durable, but + // effect.confirm has not happened and a reclaimed election reruns + // the callback. Recovery must consume the receipt, not write twice. + await perform(); + await this.effectConfirm( + effect.runId, + effect.stepId, + effect.attempt, + effect.idempotencyKey, + effect.surfacePath, + ); + return true; + }, + ); + try { + const result = await run(installed.flowPath, input('pull_request.labeled', deliveryId), hosted( + 'pull_request.labeled', + deliveryId, + async () => { + calls += 1; + return { receiptId: `bst_${'3'.repeat(64)}`, status: 'queued' }; + }, + )); + expect(result).toMatchObject({ + exitCode: 0, + report: { + ok: true, + status: 'completed', + completionReason: 'success', + completedSteps: 1, + }, + }); + expect(calls).toBe(1); + } finally { + replay.mockRestore(); + } + }, + ); + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( 'reports capability denial once as a terminal failure without fallback', async () => { const installed = await project(); From fc105cdc59b9345a526f31634e1c727912a67d86 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:42:52 -0700 Subject: [PATCH 11/14] fix(sdk): await hosted capability settlement Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- .../sdk/src/cli/hosted-software-garden-run.ts | 21 +++++++++++- .../tests/hosted-extension-protocol.test.ts | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index 135cf238..b124db27 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -115,6 +115,7 @@ export async function runHostedSoftwareGardenFlow( })); const peer = client.createPeer(); let work: Promise | undefined; + let completedWork: HostedCompletion | undefined; let capabilityFailed = false; let capabilityFailure: unknown; let workerFailure: unknown; @@ -131,6 +132,7 @@ export async function runHostedSoftwareGardenFlow( const attempt = completeHostedDispatch(peer, event, runtime, input, hosted, dataDir); work = attempt; void attempt.then(completion => { + completedWork = completion; if (completion.failed) { capabilityFailed = true; capabilityFailure = completion.failure; @@ -160,7 +162,7 @@ export async function runHostedSoftwareGardenFlow( const outcome = started.status === 'running' ? await client.runResume(started.run_id, true) : started; - const execution = await classifyOutcome( + let execution = await classifyOutcome( client, 'run', outcome, @@ -168,6 +170,23 @@ export async function runHostedSoftwareGardenFlow( socketPath, { ...lifecycle, dataDir }, ); + // The generic classifier may observe a transient parked/protocol shape + // after the isolated child is killed even though this dedicated worker is + // still holding the authoritative, uncancellable capability call. Never + // close the peer or return that intermediate report while its dispatch is + // live. The worker completion is the journal boundary for this one-step + // hosted run; classify its resulting kernel outcome instead. + const completion = work === undefined ? completedWork : await work; + if (completion !== undefined) { + execution = await classifyOutcome( + client, + 'run', + completion.outcome, + base, + socketPath, + { ...lifecycle, dataDir }, + ); + } if (execution.exitCode !== 1 || !capabilityFailed) return execution; return { ...execution, diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index d5a86b8d..1be34421 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -360,6 +360,40 @@ describe('hosted extension hostile protocol', () => { expect(calls).toBe(1); }, 15_000); + it('waits for a pending adapter to settle after the sandbox timeout', async () => { + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let settle!: (value: unknown) => void; + const adapter = new Promise(resolve => { settle = resolve; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let completed = false; + const run = exchangeHostedExtension( + child, + protocol, + stdin, + stderr, + 10, + { type: 'run' }, + async () => { markInvoked(); return await adapter; }, + ).finally(() => { completed = true; }); + protocol.write(`${JSON.stringify(capabilityFrame())}\n`); + await waitForInvocation(invoked); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(completed).toBe(false); + settle({ receiptId: 'settled-after-timeout', status: 'queued' }); + await expect(run).rejects.toMatchObject({ + code: 'plugin_unsupported', + message: expect.stringContaining('outcome is in doubt'), + }); + }, 15_000); + it('settles successful completion with captured intrinsics', async () => { const protocol = new PassThrough(); const stdin = new PassThrough(); From ca502afa60aad32b42bbf64aa6ad48857cebaad9 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:56:59 -0700 Subject: [PATCH 12/14] fix(sdk): reject late hosted frames Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- packages/sdk/src/hosted-extension-protocol.ts | 4 +++ .../tests/hosted-extension-protocol.test.ts | 30 +++++++++++++++++++ ...re-garden-babysitter-canonical-run.test.ts | 6 ++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 0f5dc1d0..c2623985 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -165,6 +165,10 @@ export async function exchangeHostedExtension( EVENT_ON(stdin, 'error', () => refuse('Hosted extension capability channel closed.')); EVENT_ON(protocol, 'error', () => refuse('Hosted extension protocol channel failed.')); EVENT_ON(protocol, 'data', chunk => { + // Streams may still deliver bytes buffered before finish killed the + // sandbox. A terminal protocol exchange must never start a capability + // from one of those late frames. + if (settled) return; buffer += STRING(chunk); if (BUFFER_BYTE_LENGTH(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); for (;;) { diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 1be34421..f1670bac 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -394,6 +394,36 @@ describe('hosted extension hostile protocol', () => { }); }, 15_000); + it('does not invoke a capability frame delivered after timeout settlement', async () => { + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let calls = 0; + const run = exchangeHostedExtension( + child, + protocol, + stdin, + stderr, + 10, + { type: 'run' }, + async () => { calls += 1; return { receiptId: 'late', status: 'queued' }; }, + ); + const observed = run.then(() => undefined, error => error as Error); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(await observed).toMatchObject({ + code: 'plugin_unsupported', + message: expect.stringContaining('sandbox timed out'), + }); + protocol.write(`${JSON.stringify(capabilityFrame())}\n`); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(calls).toBe(0); + }); + it('settles successful completion with captured intrinsics', async () => { const protocol = new PassThrough(); const stdin = new PassThrough(); diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index 7a068010..b134a06c 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -307,14 +307,16 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () await released; return { receiptId: `bst_${'2'.repeat(64)}`, status: 'queued' }; }), - timeoutMs: 25, + // Sandbox startup is part of this deadline. Leave enough time for the + // capability to begin, then deliberately hold it beyond the deadline. + timeoutMs: 1_000, }; let settled = false; const pending = run(installed.flowPath, input('pull_request.labeled', deliveryId), authority); void pending.then(() => { settled = true; }, () => { settled = true; }); await started; - await delay(75); + await delay(1_050); expect(settled).toBe(false); expect(calls).toBe(1); From 90ffb895570d8e115abb4b5d199f7f2795a0d938 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:58:28 -0700 Subject: [PATCH 13/14] test(sdk): stabilize hosted timeout ordering Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- .../tests/software-garden-babysitter-canonical-run.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts index b134a06c..1196ab97 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -309,14 +309,14 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () }), // Sandbox startup is part of this deadline. Leave enough time for the // capability to begin, then deliberately hold it beyond the deadline. - timeoutMs: 1_000, + timeoutMs: 3_000, }; let settled = false; const pending = run(installed.flowPath, input('pull_request.labeled', deliveryId), authority); void pending.then(() => { settled = true; }, () => { settled = true; }); await started; - await delay(1_050); + await delay(3_050); expect(settled).toBe(false); expect(calls).toBe(1); @@ -341,6 +341,7 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () expect(readdirSync(receipts).map(file => readFileSync(join(receipts, file), 'utf8'))).toEqual(afterCompletion); expect(calls).toBe(1); }, + 10_000, ); it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( From 0b241e7826651d158b0ca7706ec617d6560ea837 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 16:25:15 -0700 Subject: [PATCH 14/14] fix(sdk): stop at terminal hosted frame Session-Id: 01a0e943-46a7-7ad3-b4f8-8d5f31498018 --- packages/sdk/src/hosted-extension-protocol.ts | 8 +++-- .../tests/hosted-extension-protocol.test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index c2623985..17ac1ccd 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -172,6 +172,10 @@ export async function exchangeHostedExtension( buffer += STRING(chunk); if (BUFFER_BYTE_LENGTH(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); for (;;) { + // A frame earlier in this same chunk may have called finish. Stop at + // the terminal frame boundary before parsing or invoking anything + // else already buffered behind it. + if (settled) return; const end = STRING_INDEX_OF(buffer, '\n'); if (end < 0) break; const line = STRING_SLICE(buffer, 0, end); @@ -226,7 +230,7 @@ export async function exchangeHostedExtension( || message.completionReason !== 'success' || message.capabilityCalls !== 1) { return refuse('Hosted extension reported a completion without exactly one capability call.'); } - finish(undefined, frozenHostedPromiseValue({ completionReason: 'success', capabilityCalls: 1 })); + return finish(undefined, frozenHostedPromiseValue({ completionReason: 'success', capabilityCalls: 1 })); } else if (message.type === 'error') { if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { return refuse('Hosted extension emitted a malformed error frame.'); @@ -239,7 +243,7 @@ export async function exchangeHostedExtension( deferredProtocolError ??= error; return; } - finish(capabilityError ?? error); + return finish(capabilityError ?? error); } else return refuse('Hosted extension emitted an unknown protocol message.'); } }); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index f1670bac..818a05f9 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -424,6 +424,35 @@ describe('hosted extension hostile protocol', () => { expect(calls).toBe(0); }); + it('does not invoke a capability buffered after a terminal frame in the same chunk', async () => { + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let calls = 0; + const run = exchangeHostedExtension( + child, + protocol, + stdin, + stderr, + 10_000, + { type: 'run' }, + async () => { calls += 1; return { receiptId: 'late', status: 'queued' }; }, + ); + const observed = run.then(() => undefined, error => error as Error); + protocol.write(`${JSON.stringify({ type: 'error', message: 'terminal' })}\n${JSON.stringify(capabilityFrame())}\n`); + expect(await observed).toMatchObject({ + code: 'plugin_unsupported', + message: expect.stringContaining('Hosted extension failed: terminal'), + }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(calls).toBe(0); + }); + it('settles successful completion with captured intrinsics', async () => { const protocol = new PassThrough(); const stdin = new PassThrough();