From 4e242a8bdc832c831fa4e6b64e0dfc958b7ae02e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:03:21 -0700 Subject: [PATCH 01/22] 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 46ca42ab5..460668d93 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -31,7 +31,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 { @@ -183,6 +186,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; } /** @@ -214,11 +224,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; } @@ -230,6 +242,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); @@ -393,6 +415,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 d2d5a8e5d..83f3456fa 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -14,7 +14,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'; @@ -33,11 +38,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 { // Consume and close the agent-only descriptor before authored source is // imported by trigger preflight. Authored code never receives this object. @@ -56,6 +75,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 000000000..3e625f36a --- /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 a656d46eab2be359e9f56dcf0b9a8a18a08f98ee Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:12:36 -0700 Subject: [PATCH 02/22] 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 414493cb6..9713484c6 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 3762e6710e83243ff50fdd26a5f60623e4a174e3 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:23:03 -0700 Subject: [PATCH 03/22] 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 3e625f36a..ef38c1f94 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 2f8c473acf00d44a88c1ea47abdc90af63a749af Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:26:35 -0700 Subject: [PATCH 04/22] 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 83f3456fa..485656e1e 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -81,11 +81,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, }); @@ -100,7 +108,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 ef38c1f94..a0598c7c7 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 3224e3fff38bbf11656bc289b2bb1c589141ca8d Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:45:15 -0700 Subject: [PATCH 05/22] 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 9713484c6..1e8720cf7 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 460668d93..11b435d87 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -251,6 +251,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 485656e1e..41ac239c9 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -14,14 +14,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, @@ -38,10 +37,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 { /** @@ -83,58 +79,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 000000000..239d8fe18 --- /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 a0598c7c7..6acf24a59 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 000000000..917b96905 --- /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 707769ba2a8d5338ee6bf2da947ba3538cf5c99e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:59:20 -0700 Subject: [PATCH 06/22] 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 239d8fe18..165e87a98 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 284d7f4a2..747f4adde 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 9bc975849..1ea16b372 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 6acf24a59..f16454c67 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 01fd7d6182fa45d6ea614abfe92dd2853e440970 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 12:59:52 -0700 Subject: [PATCH 07/22] 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 f16454c67..54d0fcd57 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 aee4896a0f27a578b9c5314539e3b26c9a1c26d2 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 13:16:26 -0700 Subject: [PATCH 08/22] 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 1e8720cf7..485edec47 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 11b435d87..b42f6a652 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -252,11 +252,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 165e87a98..636113ec8 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 54d0fcd57..8e1ff48f0 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 9e6578a78119b8f57cbda97d1c77e5d967aa607c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 13:27:43 -0700 Subject: [PATCH 09/22] 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 636113ec8..f0bb7fe0a 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 8e1ff48f0..665fde38c 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 d7146b79cc481c3638a420b7af155873117dde19 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:26:03 -0700 Subject: [PATCH 10/22] 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 f0bb7fe0a..135cf2384 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 a468587d7..0f5dc1d05 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 665fde38c..7a0680104 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 90dd24af551a84dbb4709bfb56f863763eda298b Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:42:52 -0700 Subject: [PATCH 11/22] 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 135cf2384..b124db278 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 e509e7895..1d283c751 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -377,6 +377,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 b360936a02473dd7b0a45f202db2894beea6e080 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:56:59 -0700 Subject: [PATCH 12/22] 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 0f5dc1d05..c2623985c 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 1d283c751..4c7740bea 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -411,6 +411,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 7a0680104..b134a06ca 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 ae35e3027ccdc2e7bdb9ba51562dc2f0fb56f68c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 15:58:28 -0700 Subject: [PATCH 13/22] 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 b134a06ca..1196ab975 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 d9144779f4de917715b82115fa3d88931926107e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 28 Sep 2026 16:25:15 -0700 Subject: [PATCH 14/22] 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 c2623985c..17ac1ccd6 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 4c7740bea..72670b049 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -441,6 +441,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(); From 133b9e9130a54a4746901a7be5f56b71169d38ca Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 07:29:57 -0700 Subject: [PATCH 15/22] fix(sdk): close hosted Babysitter admission gaps --- packages/sdk/package-lock.json | 4 +- packages/sdk/package.json | 4 +- .../sdk/src/cli/hosted-software-garden-run.ts | 37 +++- packages/sdk/src/hosted-data-directory.ts | 58 ++++++ .../sdk/src/hosted-extension-isolation.ts | 7 +- .../sdk/tests/hosted-data-directory.test.ts | 55 ++++++ packages/sdk/tests/relay-peer-compat.test.ts | 20 ++ ...re-garden-babysitter-canonical-run.test.ts | 1 + ...ftware-garden-babysitter-preflight.test.ts | 171 ++++++++++++++++++ 9 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 packages/sdk/src/hosted-data-directory.ts create mode 100644 packages/sdk/tests/hosted-data-directory.test.ts create mode 100644 packages/sdk/tests/relay-peer-compat.test.ts create mode 100644 packages/sdk/tests/software-garden-babysitter-preflight.test.ts diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index a3247272f..5068ecf7c 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -32,8 +32,8 @@ "vitest": "^2.1.0" }, "peerDependencies": { - "@agent-relay/harness-driver": ">=12.3.1 <13", - "@agent-relay/sdk": ">=12.3.1 <13" + "@agent-relay/harness-driver": ">=12.3.1 <14", + "@agent-relay/sdk": ">=12.3.1 <14" }, "peerDependenciesMeta": { "@agent-relay/harness-driver": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cf2397f26..714be156f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -71,8 +71,8 @@ "directory": "packages/sdk" }, "peerDependencies": { - "@agent-relay/harness-driver": ">=12.3.1 <13", - "@agent-relay/sdk": ">=12.3.1 <13" + "@agent-relay/harness-driver": ">=12.3.1 <14", + "@agent-relay/sdk": ">=12.3.1 <14" }, "peerDependenciesMeta": { "@agent-relay/harness-driver": { diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index b124db278..327e80f94 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -6,14 +6,16 @@ import { hostedExtensionDispatchIdentity, type HostedEventIdentity, } from '../flow-extension-loader.js'; +import { assertHostedDataDirectoryIsolated } from '../hosted-data-directory.js'; import { loadHostedExtensionRuntime, + normalizeHostedBabysitterInput, runHostedCapabilityExtension, selectHostedExtensionForRuntime, type RunHostedSoftwareGardenBabysitterOptions, } from '../hosted-extension-isolation.js'; import { atomicJson, readHelperReceipt } from '../helper-storage.js'; -import { JournalClient } from '../journal-client.js'; +import { JournalClient, JournalProtocolError } from '../journal-client.js'; import { PluginError } from '../plugin-manifest.js'; import type { RunOutcome, StepDispatchEvent } from '../protocol.js'; import { SPEC_SCHEMA_VERSION } from '../spec.js'; @@ -52,10 +54,13 @@ export async function runHostedSoftwareGardenFlow( ): Promise { const base: RunReport = { ...emptyReport('run'), path }; let identity: HostedEventIdentity; + let normalizedInput: unknown; let runtime: Awaited>; let selected: Awaited>; try { identity = hostedExtensionDispatchIdentity(hosted.dispatch); + normalizedInput = normalizeHostedBabysitterInput(input, hosted.dispatch); + await assertHostedDataDirectoryIsolated(path, dataDir); runtime = await loadHostedExtensionRuntime(path); selected = await selectHostedExtensionForRuntime( runtime.installation, @@ -96,7 +101,7 @@ export async function runHostedSoftwareGardenFlow( verb: 'babysitter-turn', identity, authority: admissionIdentity, - input, + input: normalizedInput, }); const spec = toKernelSpec(compileSpec({ version: SPEC_SCHEMA_VERSION, @@ -129,7 +134,7 @@ export async function runHostedSoftwareGardenFlow( peer.close(workerFailure); return; } - const attempt = completeHostedDispatch(peer, event, runtime, input, hosted, dataDir); + const attempt = completeHostedDispatch(peer, event, runtime, normalizedInput, hosted, dataDir); work = attempt; void attempt.then(completion => { completedWork = completion; @@ -139,8 +144,6 @@ export async function runHostedSoftwareGardenFlow( } }, error => { workerFailure ??= error; - }).finally(() => { - if (work === attempt) work = undefined; }); }; @@ -153,7 +156,12 @@ export async function runHostedSoftwareGardenFlow( 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); + const started = await startHostedRun( + client, + spec, + 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 @@ -207,6 +215,23 @@ export async function runHostedSoftwareGardenFlow( } } +/** @internal Compatibility seam for deterministic protocol tests. */ +export async function startHostedRun( + client: JournalClient, + spec: Parameters[0], + admissionKey: string, + watch: boolean, +): Promise { + if (!watch) return await client.runStart(spec, undefined, admissionKey); + try { + return await client.runStart(spec, undefined, admissionKey, true); + } catch (error) { + if (!(error instanceof JournalProtocolError) || error.code !== 'bad_request' + || !/unknown field `watch`/.test(error.message)) throw error; + return await client.runStart(spec, undefined, admissionKey); + } +} + type HostedCompletion = | { readonly outcome: RunOutcome; readonly failed: false } | { readonly outcome: RunOutcome; readonly failed: true; readonly failure: unknown }; diff --git a/packages/sdk/src/hosted-data-directory.ts b/packages/sdk/src/hosted-data-directory.ts new file mode 100644 index 000000000..64a10f807 --- /dev/null +++ b/packages/sdk/src/hosted-data-directory.ts @@ -0,0 +1,58 @@ +import { realpath } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { findHostedProject } from './hosted-project.js'; +import { PluginError } from './plugin-manifest.js'; + +const REALPATH = realpath; +const PATH_BASENAME = basename; +const PATH_DIRNAME = dirname; +const PATH_IS_ABSOLUTE = isAbsolute; +const PATH_RELATIVE = relative; +const PATH_RESOLVE = resolve; +const PATH_SEPARATOR = sep; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, + search: string, +) => boolean; + +/** + * Hosted source authority excludes the conventional `.relayflowd` tree. A + * caller-selected journal inside any other part of the project would mutate + * bytes covered by that authority after daemon admission, so refuse it before + * a socket is opened. Existing symlink parents are canonicalized as well. + */ +export async function assertHostedDataDirectoryIsolated( + flowPath: string, + dataDir: string, +): Promise { + const origin = await REALPATH(PATH_RESOLVE(flowPath)); + const projectRoot = await REALPATH(findHostedProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin)); + const target = await canonicalFuturePath(PATH_RESOLVE(dataDir)); + const relativePath = PATH_RELATIVE(projectRoot, target); + const inside = relativePath === '' || (!PATH_IS_ABSOLUTE(relativePath) + && relativePath !== '..' && !STRING_STARTS_WITH(relativePath, `..${PATH_SEPARATOR}`)); + if (!inside) return; + if (relativePath === '.relayflowd' + || STRING_STARTS_WITH(relativePath, `.relayflowd${PATH_SEPARATOR}`)) return; + throw new PluginError( + 'plugin_source_invalid', + 'Hosted Software Garden data must use the project .relayflowd directory or a directory outside the project.', + ); +} + +async function canonicalFuturePath(path: string): Promise { + const suffix: string[] = []; + let cursor = path; + for (;;) { + try { + const parent = await REALPATH(cursor); + return PATH_RESOLVE(parent, ...suffix); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const next = PATH_DIRNAME(cursor); + if (next === cursor) throw error; + suffix.unshift(PATH_BASENAME(cursor)); + cursor = next; + } + } +} diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 560a99f0e..13011c827 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -80,6 +80,7 @@ export interface HostedCapabilityAuthority { version: string; ref: string; digest: string; + manifestSha256: string; }>; } @@ -195,7 +196,7 @@ export async function runVerifiedNativeExtensionSandbox( assertCapabilityOnlyManifest(options.manifest); const identity = hostedExtensionDispatchIdentity(options.dispatch); assertManifestRoutes(options.manifest, identity); - const normalizedInput = babysitterInput(options.input, options.dispatch); + const normalizedInput = normalizeHostedBabysitterInput(options.input, options.dispatch); const versions = runtimeVersions(); const authority: HostedCapabilityAuthority = OBJECT_FREEZE({ dispatch: options.dispatch, @@ -204,6 +205,7 @@ export async function runVerifiedNativeExtensionSandbox( version: options.manifest.version, ref: options.artifact.ref, digest: options.artifact.digest, + manifestSha256: options.artifact.manifestSha256, }), }); return await runHostedExtensionSandbox({ @@ -361,7 +363,8 @@ export function hostedManifestRoutes( return false; } -function babysitterInput(input: unknown, dispatch: HostedExtensionDispatch): unknown { +/** @internal Validate and snapshot hosted input before journal admission. */ +export function normalizeHostedBabysitterInput(input: unknown, dispatch: HostedExtensionDispatch): unknown { const snapshot = boundedJsonSnapshot(input, 'hosted extension input'); const top = exactRecord(snapshot, 'Hosted extension input', ['event', 'pullRequest']); const event = exactRecord(top.event, 'Hosted extension event', ['provider', 'eventType', 'deliveryId']); diff --git a/packages/sdk/tests/hosted-data-directory.test.ts b/packages/sdk/tests/hosted-data-directory.test.ts new file mode 100644 index 000000000..4d43a5908 --- /dev/null +++ b/packages/sdk/tests/hosted-data-directory.test.ts @@ -0,0 +1,55 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { assertHostedDataDirectoryIsolated } from '../src/hosted-data-directory.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { force: true, recursive: true }))); +}); + +async function project(): Promise<{ root: string; flowPath: string }> { + const root = await mkdtemp(join(tmpdir(), 'hosted-data-directory-')); + roots.push(root); + const flowPath = join(root, 'software-factory.flow.yaml'); + await writeFile(join(root, 'flows.json'), '{}\n'); + await writeFile(flowPath, 'version: v1\n'); + return { root: await realpath(root), flowPath }; +} + +describe('hosted data directory isolation', () => { + it('allows the conventional project journal and an external directory', async () => { + const installed = await project(); + const external = await mkdtemp(join(tmpdir(), 'hosted-data-external-')); + roots.push(external); + + await expect(assertHostedDataDirectoryIsolated( + installed.flowPath, + join(installed.root, '.relayflowd', 'nested'), + )).resolves.toBeUndefined(); + await expect(assertHostedDataDirectoryIsolated(installed.flowPath, external)).resolves.toBeUndefined(); + }); + + it('refuses a custom in-project directory even when it does not exist', async () => { + const installed = await project(); + await expect(assertHostedDataDirectoryIsolated( + installed.flowPath, + join(installed.root, 'state', 'journal'), + )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + }); + + it('canonicalizes existing symlink parents before applying the boundary', async () => { + const installed = await project(); + const outside = await mkdtemp(join(tmpdir(), 'hosted-data-link-')); + roots.push(outside); + await mkdir(join(installed.root, 'state')); + await symlink(join(installed.root, 'state'), join(outside, 'journal-link')); + + await expect(assertHostedDataDirectoryIsolated( + installed.flowPath, + join(outside, 'journal-link', 'nested'), + )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + }); +}); diff --git a/packages/sdk/tests/relay-peer-compat.test.ts b/packages/sdk/tests/relay-peer-compat.test.ts new file mode 100644 index 000000000..443c224f0 --- /dev/null +++ b/packages/sdk/tests/relay-peer-compat.test.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; + +const RELAY_PEERS = ['@agent-relay/harness-driver', '@agent-relay/sdk'] as const; +const RANGE = '>=12.3.1 <14'; + +describe('Agent Relay peer compatibility', () => { + it('accepts Relay 12 and 13 consistently in the package and lock metadata', async () => { + const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); + const packageLock = JSON.parse(await readFile(new URL('../package-lock.json', import.meta.url), 'utf8')); + const lockedRoot = packageLock.packages['']; + + for (const dependency of RELAY_PEERS) { + expect(packageJson.peerDependencies[dependency]).toBe(RANGE); + expect(packageJson.peerDependenciesMeta[dependency]).toEqual({ optional: true }); + expect(lockedRoot.peerDependencies[dependency]).toBe(RANGE); + expect(lockedRoot.peerDependenciesMeta[dependency]).toEqual({ optional: true }); + } + }); +}); 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 1196ab975..888272917 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -251,6 +251,7 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () version: '0.2.0', ref: REF, digest: DIGEST, + manifestSha256: MANIFEST_SHA256, }); expect(request).toEqual({ delivery: { deliveryId, diff --git a/packages/sdk/tests/software-garden-babysitter-preflight.test.ts b/packages/sdk/tests/software-garden-babysitter-preflight.test.ts new file mode 100644 index 000000000..570b59a7b --- /dev/null +++ b/packages/sdk/tests/software-garden-babysitter-preflight.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +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 { startHostedRun } from '../src/cli/hosted-software-garden-run.js'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; +import { JournalClient, JournalProtocolError } from '../src/journal-client.js'; +import { entriesFromDirectory, fakeGithub } from './fake-github.js'; + +const NATIVE_SHA = '8b33ebab8347514f80d9da5a81206a087f641714'; +const REF = `github:AgentWorkforce/flows@${NATIVE_SHA}#extensions/babysitter`; +const entries = entriesFromDirectory(resolve('../..', 'extensions/babysitter'), 'extensions/babysitter'); +const roots: string[] = []; + +afterAll(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +async function project(): Promise<{ root: string; flowPath: string }> { + const root = mkdtempSync(join(tmpdir(), 'software-garden-preflight-')); + 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'] })); + const io = { stdout: () => {}, stderr: (message: string) => { throw new Error(message); } }; + const github = fakeGithub({ + 'AgentWorkforce/flows': { refs: {}, commits: { [NATIVE_SHA]: { entries } } }, + }); + expect(await addExtensionPlugin(REF, io, { + cwd: root, + fetch: github.fetch, + now: () => new Date('2026-09-28T00:00:00Z'), + versions: { sdk: '2.0.33', surface: '2.0.33' }, + })).toBe(0); + return { root, flowPath }; +} + +function input() { + return { + event: { + provider: 'github', + eventType: 'pull_request.labeled', + deliveryId: 'delivery-canonical', + }, + pullRequest: { + host: 'github', + owner: 'AgentWorkforce', + repo: 'flows', + number: 584, + headSha: 'a'.repeat(40), + }, + }; +} + +function hosted( + queue: NonNullable['babysitterTurn']['queue'], +): NonNullable { + return { + dispatch: hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', + eventType: 'pull_request.labeled', + deliveryId: 'delivery-canonical', + }), + babysitterTurn: { queue }, + }; +} + +async function run(flowPath: string, descriptor: unknown) { + const stdout: string[] = []; + let calls = 0; + const exitCode = await runCli([ + 'run', flowPath, '--input', JSON.stringify(descriptor), + '--data-dir', join(flowPath, '..', '.relayflowd'), '--json', '--no-observer-link', + ], { + stdout: line => stdout.push(line), + stderr: () => {}, + }, { + hostedSoftwareGardenBabysitter: hosted(async () => { + calls += 1; + return { receiptId: `bst_${'4'.repeat(64)}`, status: 'queued' }; + }), + }); + return { exitCode, report: JSON.parse(stdout.at(-1) ?? '{}'), calls }; +} + +describe('hosted Software Garden admission boundaries', () => { + it('retries admission without watch for an older journal daemon', async () => { + const expected = { run_id: 'run-compat' } as Awaited>; + const runStart = vi.fn() + .mockRejectedValueOnce(new JournalProtocolError('bad_request', 'unknown field `watch`')) + .mockResolvedValueOnce(expected); + const client = { runStart } as unknown as JournalClient; + + await expect(startHostedRun( + client, + {} as Parameters[0], + 'hosted-babysitter:digest', + true, + )).resolves.toBe(expected); + expect(runStart.mock.calls).toEqual([ + [expect.anything(), undefined, 'hosted-babysitter:digest', true], + [expect.anything(), undefined, 'hosted-babysitter:digest'], + ]); + }); + + it('refuses malformed verified input before creating a journal run', async () => { + const installed = await project(); + const malformed = input(); + malformed.pullRequest.number = 0; + const result = await run(installed.flowPath, malformed); + expect(result).toMatchObject({ exitCode: 2, report: { ok: false }, calls: 0 }); + expect(result.report.diagnostics).toEqual([ + expect.objectContaining({ severity: 'refusal', kind: 'plugin_event_unroutable' }), + ]); + expect(existsSync(join(installed.root, '.relayflowd'))).toBe(false); + }); + + it('refuses a custom in-project journal directory before daemon admission', async () => { + const installed = await project(); + const stdout: string[] = []; + let calls = 0; + const dataDir = join(installed.root, 'state'); + const exitCode = await runCli([ + 'run', installed.flowPath, '--input', JSON.stringify(input()), + '--data-dir', dataDir, '--json', '--no-observer-link', + ], { + stdout: line => stdout.push(line), + stderr: () => {}, + }, { + hostedSoftwareGardenBabysitter: hosted(async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + }), + }); + expect(exitCode).toBe(2); + expect(JSON.parse(stdout.at(-1) ?? '{}')).toMatchObject({ + ok: false, + diagnostics: [expect.objectContaining({ + severity: 'refusal', + kind: 'plugin_source_invalid', + message: expect.stringContaining('.relayflowd'), + })], + }); + expect(existsSync(dataDir)).toBe(false); + expect(calls).toBe(0); + }); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'reports a rejected journal completion instead of returning an intermediate run', async () => { + const installed = await project(); + const rejected = vi.spyOn(JournalClient.prototype, 'stepComplete') + .mockRejectedValueOnce(new Error('step completion rejected')); + try { + const result = await run(installed.flowPath, input()); + expect(result).toMatchObject({ + exitCode: 1, + report: { + ok: false, + diagnostics: [expect.objectContaining({ + severity: 'failure', + kind: 'protocol_error', + message: expect.stringContaining('step completion rejected'), + })], + }, + }); + } finally { + rejected.mockRestore(); + } + }, + ); +}); From 27a9230a386362753509d6c0b3653bac16b8aaff Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 07:50:03 -0700 Subject: [PATCH 16/22] fix(sdk): drain hosted work on control failure --- .../sdk/src/cli/hosted-software-garden-run.ts | 15 +++++- ...ftware-garden-babysitter-preflight.test.ts | 52 +++++++++++++++++++ 2 files changed, 66 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 327e80f94..a74458535 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -206,7 +206,20 @@ export async function runHostedSoftwareGardenFlow( }, }; } catch (error) { - return protocolFailure('run', base, socketPath, workerFailure ?? error); + // Classification/control failure does not cancel an external capability + // that has already started. Drain its journal completion before closing + // the peer, otherwise an immediate same-delivery retry can reclaim the + // recorded-but-unconfirmed election while the first provider write is + // still in flight. + let failure = error; + if (work !== undefined) { + try { + await work; + } catch (settlementError) { + failure = settlementError; + } + } + return protocolFailure('run', base, socketPath, workerFailure ?? failure); } finally { if (lifecycle.onJournalEntry !== undefined) client.off('entry', lifecycle.onJournalEntry); peer.off('step.dispatch', dispatch); diff --git a/packages/sdk/tests/software-garden-babysitter-preflight.test.ts b/packages/sdk/tests/software-garden-babysitter-preflight.test.ts index 570b59a7b..e3c2a74ef 100644 --- a/packages/sdk/tests/software-garden-babysitter-preflight.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-preflight.test.ts @@ -1,9 +1,11 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +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 { runDirectFlow } from '../src/cli/direct-run.js'; import { startHostedRun } from '../src/cli/hosted-software-garden-run.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; import { JournalClient, JournalProtocolError } from '../src/journal-client.js'; @@ -168,4 +170,54 @@ describe('hosted Software Garden admission boundaries', () => { } }, ); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'drains an in-flight capability before returning a classification cancellation', async () => { + const installed = await project(); + const dataDir = join(installed.root, '.relayflowd'); + const controller = new AbortController(); + let calls = 0; + let release!: () => void; + let started!: () => void; + const capabilityStarted = new Promise(resolveStarted => { started = resolveStarted; }); + const capabilityRelease = new Promise(resolveRelease => { release = resolveRelease; }); + const authority = hosted(async () => { + calls += 1; + started(); + await capabilityRelease; + return { receiptId: `bst_${'5'.repeat(64)}`, status: 'queued' }; + }); + + let settled = false; + const first = runDirectFlow( + installed.flowPath, + JSON.stringify(input()), + dataDir, + { hostedSoftwareGardenBabysitter: authority, signal: controller.signal }, + ).finally(() => { settled = true; }); + await capabilityStarted; + controller.abort(new Error('embedded caller canceled')); + await delay(100); + expect(settled).toBe(false); + + release(); + await expect(first).resolves.toMatchObject({ + exitCode: 1, + report: { + ok: false, + diagnostics: [expect.objectContaining({ + kind: 'protocol_error', + message: expect.stringContaining('canceled'), + })], + }, + }); + await expect(runDirectFlow( + installed.flowPath, + JSON.stringify(input()), + dataDir, + { hostedSoftwareGardenBabysitter: authority }, + )).resolves.toMatchObject({ exitCode: 0, report: { ok: true } }); + expect(calls).toBe(1); + }, + ); }); From 44abdb5b0b79400c70e4903c2102834463bde953 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 07:58:09 -0700 Subject: [PATCH 17/22] fix(sdk): surface hosted worker rejection promptly --- .../sdk/src/cli/hosted-software-garden-run.ts | 34 +++++++++++++------ .../tests/babysitter-native-extension.test.ts | 1 + ...ware-garden-babysitter-composition.test.ts | 4 +-- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index a74458535..0c9733747 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -124,13 +124,20 @@ export async function runHostedSoftwareGardenFlow( let capabilityFailed = false; let capabilityFailure: unknown; let workerFailure: unknown; + let signalWorkerFailure!: (error: unknown) => void; + const workerFailureSignal = new Promise(resolve => { signalWorkerFailure = resolve; }); + + const recordWorkerFailure = (error: unknown): void => { + workerFailure ??= error; + signalWorkerFailure(workerFailure); + }; 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.'); + recordWorkerFailure(new Error('Hosted Babysitter worker received an unexpected dispatch.')); peer.close(workerFailure); return; } @@ -143,12 +150,12 @@ export async function runHostedSoftwareGardenFlow( capabilityFailure = completion.failure; } }, error => { - workerFailure ??= error; + recordWorkerFailure(error); }); }; peer.on('step.dispatch', dispatch); - peer.on('error', error => { workerFailure ??= error; }); + peer.on('error', recordWorkerFailure); if (lifecycle.onJournalEntry !== undefined) client.on('entry', lifecycle.onJournalEntry); try { await peer.connect(); @@ -170,14 +177,19 @@ export async function runHostedSoftwareGardenFlow( const outcome = started.status === 'running' ? await client.runResume(started.run_id, true) : started; - let execution = await classifyOutcome( - client, - 'run', - outcome, - base, - socketPath, - { ...lifecycle, dataDir }, - ); + const classified = await Promise.race([ + classifyOutcome( + client, + 'run', + outcome, + base, + socketPath, + { ...lifecycle, dataDir }, + ).then(execution => ({ type: 'execution' as const, execution })), + workerFailureSignal.then(failure => ({ type: 'worker-failure' as const, failure })), + ]); + if (classified.type === 'worker-failure') throw classified.failure; + let execution = classified.execution; // 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 diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index c3d0ee1d7..907f6a582 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -147,6 +147,7 @@ describe('native Babysitter extension', () => { expect(authority.dispatch).toBe(dispatch); expect(authority.extension).toEqual({ name: 'babysitter', version: '0.2.0', ref: REF, digest: DIGEST, + manifestSha256: MANIFEST_SHA256, }); return { receiptId: `bst_${'a'.repeat(64)}`, status: 'queued' }; } }, diff --git a/packages/sdk/tests/software-garden-babysitter-composition.test.ts b/packages/sdk/tests/software-garden-babysitter-composition.test.ts index b2becf0b3..b436d2a18 100644 --- a/packages/sdk/tests/software-garden-babysitter-composition.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-composition.test.ts @@ -126,8 +126,8 @@ describe('canonical Software Garden + Babysitter composition', () => { expect(calls.map(call => (call as { authority: { dispatch: { deliveryId: string } } }).authority.dispatch.deliveryId)) .toEqual([deliveryId, deliveryId]); expect(calls.map(call => (call as { authority: { extension: unknown } }).authority.extension)).toEqual([ - { name: 'babysitter', version: '0.2.0', ref: REF, digest: DIGEST }, - { name: 'babysitter', version: '0.2.0', ref: REF, digest: DIGEST }, + { name: 'babysitter', version: '0.2.0', ref: REF, digest: DIGEST, manifestSha256: MANIFEST_SHA256 }, + { name: 'babysitter', version: '0.2.0', ref: REF, digest: DIGEST, manifestSha256: MANIFEST_SHA256 }, ]); }, ); From bec70892915dd95eb4ddf6d6bcfc8c15be2eda0d Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 08:12:44 -0700 Subject: [PATCH 18/22] test: harden hosted timeout regression --- ...re-garden-babysitter-canonical-run.test.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 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 888272917..81eac49b2 100644 --- a/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts +++ b/packages/sdk/tests/software-garden-babysitter-canonical-run.test.ts @@ -316,13 +316,25 @@ describe('canonical run dispatches the installed Software Garden Babysitter', () 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(3_050); - expect(settled).toBe(false); - expect(calls).toBe(1); - - release(); - const result = await pending; + let result: Awaited | undefined; + try { + const startState = await Promise.race([ + started.then(() => 'started' as const), + pending.then(() => 'settled' as const, () => 'settled' as const), + delay(5_000).then(() => 'watchdog' as const), + ]); + if (startState !== 'started') { + throw new Error(startState === 'settled' + ? 'Hosted run settled before the capability started.' + : 'Hosted capability did not start within 5 seconds.'); + } + await delay(3_500); + expect(settled).toBe(false); + expect(calls).toBe(1); + } finally { + release(); + result = await pending; + } expect(result).toMatchObject({ exitCode: 1, report: { @@ -342,7 +354,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, + 15_000, ); it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( From b31385336017a5438bfa6a6a4a146c7090c1c8df Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 08:20:56 -0700 Subject: [PATCH 19/22] fix: contain hosted babysitter receipts --- docs/BABYSITTER-CATALOG-HANDOFF.md | 14 +++++-- .../sdk/src/cli/hosted-software-garden-run.ts | 13 ++++--- packages/sdk/src/flow-extension-loader.ts | 16 ++++---- packages/sdk/src/hosted-data-directory.ts | 37 +++++++++++++++++-- .../sdk/tests/hosted-data-directory.test.ts | 25 ++++++++++++- ...re-garden-babysitter-canonical-run.test.ts | 2 +- packages/sdk/tsconfig.tests.json | 4 ++ 7 files changed, 88 insertions(+), 23 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 485edec47..7193ed60d 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -17,16 +17,22 @@ 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 +capability; no CLI flag or flow input can mint either. The dispatch constructor +validates shape but does not authenticate a delivery: the embedding process is +the trust boundary and must call it only after authenticating the integration +delivery. Code able to construct this option and supply the queue capability is +therefore trusted host code. The canonical authored 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. +journal rather than an in-memory synthetic result. Ordinary command and authored +flow paths refuse the hosted authority. The exported low-level sandbox helper is +available to trusted embedders and verifier tests, but it performs no journal +admission and is not an owning action path. 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 diff --git a/packages/sdk/src/cli/hosted-software-garden-run.ts b/packages/sdk/src/cli/hosted-software-garden-run.ts index 0c9733747..470dfceee 100644 --- a/packages/sdk/src/cli/hosted-software-garden-run.ts +++ b/packages/sdk/src/cli/hosted-software-garden-run.ts @@ -57,10 +57,11 @@ export async function runHostedSoftwareGardenFlow( let normalizedInput: unknown; let runtime: Awaited>; let selected: Awaited>; + let receiptDirectory: string; try { identity = hostedExtensionDispatchIdentity(hosted.dispatch); normalizedInput = normalizeHostedBabysitterInput(input, hosted.dispatch); - await assertHostedDataDirectoryIsolated(path, dataDir); + ({ dataDir, receiptDirectory } = await assertHostedDataDirectoryIsolated(path, dataDir)); runtime = await loadHostedExtensionRuntime(path); selected = await selectHostedExtensionForRuntime( runtime.installation, @@ -141,7 +142,7 @@ export async function runHostedSoftwareGardenFlow( peer.close(workerFailure); return; } - const attempt = completeHostedDispatch(peer, event, runtime, normalizedInput, hosted, dataDir); + const attempt = completeHostedDispatch(peer, event, runtime, normalizedInput, hosted, receiptDirectory); work = attempt; void attempt.then(completion => { completedWork = completion; @@ -267,7 +268,7 @@ async function completeHostedDispatch( runtime: Awaited>, input: unknown, hosted: HostedSoftwareGardenRunOptions, - dataDir: string, + receiptDirectory: string, ): Promise { let result: unknown; let effectRecorded = false; @@ -283,7 +284,7 @@ async function completeHostedDispatch( ...(hosted.timeoutMs === undefined ? {} : { timeoutMs: hosted.timeoutMs }), babysitterTurn: { queue: async (request, authority) => { - const file = hostedReceiptPath(dataDir, dispatch); + const file = hostedReceiptPath(receiptDirectory, dispatch); let receipt: unknown; const performed = await peer.performEffect({ runId: dispatch.run_id, @@ -350,11 +351,11 @@ async function completeHostedDispatch( return failed ? { outcome, failed: true, failure } : { outcome, failed: false }; } -function hostedReceiptPath(dataDir: string, dispatch: StepDispatchEvent): string { +function hostedReceiptPath(receiptDirectory: 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`); + return join(receiptDirectory, `${name}.json`); } function pluginRefusal(base: RunReport, error: PluginError): RunExecution { diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index 41882a159..f0ea5a46a 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -222,10 +222,10 @@ function subscriptionOf(handler: TriggerHandler): { provider: string; event: str } /** - * Server-authenticated integration delivery metadata. This is executor - * authority, not authored input: a caller must derive it from its verified - * delivery record and pass it out of band. User-controlled `input.event` - * objects never become this value. + * Host-asserted integration delivery metadata. This is executor authority, + * not authored input: the trusted embedding host must derive it from its + * authenticated delivery record and pass it out of band with the capability. + * User-controlled `input.event` objects never become this value. */ const HOSTED_EXTENSION_DISPATCH_AUTHORITY = Symbol('hosted-extension-dispatch-authority'); @@ -273,9 +273,11 @@ export function hostedExtensionDispatchIdentity(dispatch: unknown): HostedEventI } /** - * Brand metadata only after the host has authenticated the integration - * delivery. The symbol is deliberately not serializable, so copying a direct - * run's JSON into executor options cannot mint dispatch authority. + * Brand metadata only after the embedding host has authenticated the + * integration delivery. This constructor validates shape; it is not itself an + * authenticator. Code able to construct the non-serializable hosted options + * and supply the queue capability is inside the host trust boundary. The + * symbol prevents authored JSON from becoming dispatch authority. */ export function hostedExtensionDispatchFromVerifiedDelivery( delivery: Omit, diff --git a/packages/sdk/src/hosted-data-directory.ts b/packages/sdk/src/hosted-data-directory.ts index 64a10f807..e6e976ffa 100644 --- a/packages/sdk/src/hosted-data-directory.ts +++ b/packages/sdk/src/hosted-data-directory.ts @@ -1,9 +1,11 @@ -import { realpath } from 'node:fs/promises'; +import { lstat, mkdir, realpath } from 'node:fs/promises'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { findHostedProject } from './hosted-project.js'; import { PluginError } from './plugin-manifest.js'; const REALPATH = realpath; +const LSTAT = lstat; +const MKDIR = mkdir; const PATH_BASENAME = basename; const PATH_DIRNAME = dirname; const PATH_IS_ABSOLUTE = isAbsolute; @@ -24,10 +26,29 @@ const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsW export async function assertHostedDataDirectoryIsolated( flowPath: string, dataDir: string, -): Promise { +): Promise<{ readonly dataDir: string; readonly receiptDirectory: string }> { const origin = await REALPATH(PATH_RESOLVE(flowPath)); const projectRoot = await REALPATH(findHostedProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin)); const target = await canonicalFuturePath(PATH_RESOLVE(dataDir)); + assertOutsideHostedSource(projectRoot, target); + // The hosted worker writes its durable provider receipt below this child. + // Recheck the derived path so an existing symlink cannot redirect that + // write from the excluded data tree back into reviewed project source. + const receiptPath = PATH_RESOLVE(target, 'hosted-extension-receipts'); + await MKDIR(receiptPath, { recursive: true }); + const receiptEntry = await LSTAT(receiptPath); + if (receiptEntry.isSymbolicLink() || !receiptEntry.isDirectory()) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted Software Garden receipt storage must be a real directory inside its data directory.', + ); + } + const receipts = await REALPATH(receiptPath); + assertInsideDataDirectory(target, receipts); + return { dataDir: target, receiptDirectory: receipts }; +} + +function assertOutsideHostedSource(projectRoot: string, target: string): void { const relativePath = PATH_RELATIVE(projectRoot, target); const inside = relativePath === '' || (!PATH_IS_ABSOLUTE(relativePath) && relativePath !== '..' && !STRING_STARTS_WITH(relativePath, `..${PATH_SEPARATOR}`)); @@ -36,7 +57,17 @@ export async function assertHostedDataDirectoryIsolated( || STRING_STARTS_WITH(relativePath, `.relayflowd${PATH_SEPARATOR}`)) return; throw new PluginError( 'plugin_source_invalid', - 'Hosted Software Garden data must use the project .relayflowd directory or a directory outside the project.', + 'Hosted Software Garden data paths must stay in the project .relayflowd directory or outside the project.', + ); +} + +function assertInsideDataDirectory(dataDir: string, target: string): void { + const relativePath = PATH_RELATIVE(dataDir, target); + if (relativePath !== '' && !PATH_IS_ABSOLUTE(relativePath) + && relativePath !== '..' && !STRING_STARTS_WITH(relativePath, `..${PATH_SEPARATOR}`)) return; + throw new PluginError( + 'plugin_source_invalid', + 'Hosted Software Garden receipt storage must stay inside its data directory.', ); } diff --git a/packages/sdk/tests/hosted-data-directory.test.ts b/packages/sdk/tests/hosted-data-directory.test.ts index 4d43a5908..dc9b611f5 100644 --- a/packages/sdk/tests/hosted-data-directory.test.ts +++ b/packages/sdk/tests/hosted-data-directory.test.ts @@ -28,8 +28,14 @@ describe('hosted data directory isolation', () => { await expect(assertHostedDataDirectoryIsolated( installed.flowPath, join(installed.root, '.relayflowd', 'nested'), - )).resolves.toBeUndefined(); - await expect(assertHostedDataDirectoryIsolated(installed.flowPath, external)).resolves.toBeUndefined(); + )).resolves.toEqual({ + dataDir: join(installed.root, '.relayflowd', 'nested'), + receiptDirectory: join(installed.root, '.relayflowd', 'nested', 'hosted-extension-receipts'), + }); + await expect(assertHostedDataDirectoryIsolated(installed.flowPath, external)).resolves.toEqual({ + dataDir: await realpath(external), + receiptDirectory: join(await realpath(external), 'hosted-extension-receipts'), + }); }); it('refuses a custom in-project directory even when it does not exist', async () => { @@ -52,4 +58,19 @@ describe('hosted data directory isolation', () => { join(outside, 'journal-link', 'nested'), )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); }); + + it('refuses a receipt-directory symlink back into hosted project source', async () => { + const installed = await project(); + await mkdir(join(installed.root, '.relayflowd')); + await mkdir(join(installed.root, 'source-state')); + await symlink( + join(installed.root, 'source-state'), + join(installed.root, '.relayflowd', 'hosted-extension-receipts'), + ); + + await expect(assertHostedDataDirectoryIsolated( + installed.flowPath, + join(installed.root, '.relayflowd'), + )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + }); }); 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 81eac49b2..9048edb06 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', () const deliveryId = 'delivery-reclaimed-receipt'; let calls = 0; const replay = vi.spyOn(JournalClient.prototype, 'performEffect').mockImplementationOnce( - async function (effect, perform) { + async function (this: JournalClient, effect, perform) { const { deduped } = await this.effectRecord( effect.runId, effect.stepId, diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 04045eaa6..7163d582f 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -43,6 +43,10 @@ "tests/plugin-store-bounds.test.ts", "tests/hosted-extension-protocol-intrinsics.test.ts", "tests/hosted-extension-protocol.test.ts", + "tests/software-garden-babysitter-canonical-run.test.ts", + "tests/software-garden-babysitter-preflight.test.ts", + "tests/hosted-data-directory.test.ts", + "tests/relay-peer-compat.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", "tests/journal-client-loopback.ts", From 74cb66b5dbe048ebb3c04017bea1fa8426c6181a Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 08:25:43 -0700 Subject: [PATCH 20/22] fix: normalize hosted receipt refusals --- packages/sdk/src/hosted-data-directory.ts | 10 ++++++-- .../sdk/tests/hosted-data-directory.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/hosted-data-directory.ts b/packages/sdk/src/hosted-data-directory.ts index e6e976ffa..9fc3c2f0c 100644 --- a/packages/sdk/src/hosted-data-directory.ts +++ b/packages/sdk/src/hosted-data-directory.ts @@ -35,8 +35,14 @@ export async function assertHostedDataDirectoryIsolated( // Recheck the derived path so an existing symlink cannot redirect that // write from the excluded data tree back into reviewed project source. const receiptPath = PATH_RESOLVE(target, 'hosted-extension-receipts'); - await MKDIR(receiptPath, { recursive: true }); - const receiptEntry = await LSTAT(receiptPath); + let receiptEntry: Awaited>; + try { + receiptEntry = await LSTAT(receiptPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + await MKDIR(receiptPath, { recursive: true }); + receiptEntry = await LSTAT(receiptPath); + } if (receiptEntry.isSymbolicLink() || !receiptEntry.isDirectory()) { throw new PluginError( 'plugin_source_invalid', diff --git a/packages/sdk/tests/hosted-data-directory.test.ts b/packages/sdk/tests/hosted-data-directory.test.ts index dc9b611f5..6395e95d4 100644 --- a/packages/sdk/tests/hosted-data-directory.test.ts +++ b/packages/sdk/tests/hosted-data-directory.test.ts @@ -73,4 +73,29 @@ describe('hosted data directory isolation', () => { join(installed.root, '.relayflowd'), )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); }); + + it('refuses a regular file at the receipt-directory path', async () => { + const installed = await project(); + await mkdir(join(installed.root, '.relayflowd')); + await writeFile(join(installed.root, '.relayflowd', 'hosted-extension-receipts'), 'not a directory\n'); + + await expect(assertHostedDataDirectoryIsolated( + installed.flowPath, + join(installed.root, '.relayflowd'), + )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + }); + + it('refuses a dangling symlink at the receipt-directory path', async () => { + const installed = await project(); + await mkdir(join(installed.root, '.relayflowd')); + await symlink( + join(installed.root, 'missing-receipt-target'), + join(installed.root, '.relayflowd', 'hosted-extension-receipts'), + ); + + await expect(assertHostedDataDirectoryIsolated( + installed.flowPath, + join(installed.root, '.relayflowd'), + )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + }); }); From 541314fa79a06f80398106a5e4149c4a56bb3102 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 08:42:10 -0700 Subject: [PATCH 21/22] fix: preserve hosted daemon socket identity --- packages/sdk/src/hosted-data-directory.ts | 8 +++++-- .../sdk/tests/hosted-data-directory.test.ts | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/hosted-data-directory.ts b/packages/sdk/src/hosted-data-directory.ts index 9fc3c2f0c..686177638 100644 --- a/packages/sdk/src/hosted-data-directory.ts +++ b/packages/sdk/src/hosted-data-directory.ts @@ -29,7 +29,8 @@ export async function assertHostedDataDirectoryIsolated( ): Promise<{ readonly dataDir: string; readonly receiptDirectory: string }> { const origin = await REALPATH(PATH_RESOLVE(flowPath)); const projectRoot = await REALPATH(findHostedProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin)); - const target = await canonicalFuturePath(PATH_RESOLVE(dataDir)); + const requestedDataDir = PATH_RESOLVE(dataDir); + const target = await canonicalFuturePath(requestedDataDir); assertOutsideHostedSource(projectRoot, target); // The hosted worker writes its durable provider receipt below this child. // Recheck the derived path so an existing symlink cannot redirect that @@ -51,7 +52,10 @@ export async function assertHostedDataDirectoryIsolated( } const receipts = await REALPATH(receiptPath); assertInsideDataDirectory(target, receipts); - return { dataDir: target, receiptDirectory: receipts }; + // Socket identity is intentionally based on the caller's lexical absolute + // path. A running daemon started through a symlink ancestor hashes that same + // spelling, while receipt I/O uses the separately validated canonical path. + return { dataDir: requestedDataDir, receiptDirectory: receipts }; } function assertOutsideHostedSource(projectRoot: string, target: string): void { diff --git a/packages/sdk/tests/hosted-data-directory.test.ts b/packages/sdk/tests/hosted-data-directory.test.ts index 6395e95d4..e38b8c75c 100644 --- a/packages/sdk/tests/hosted-data-directory.test.ts +++ b/packages/sdk/tests/hosted-data-directory.test.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import { socketPathFor } from '../src/daemon-connection.js'; import { assertHostedDataDirectoryIsolated } from '../src/hosted-data-directory.js'; const roots: string[] = []; @@ -33,11 +34,30 @@ describe('hosted data directory isolation', () => { receiptDirectory: join(installed.root, '.relayflowd', 'nested', 'hosted-extension-receipts'), }); await expect(assertHostedDataDirectoryIsolated(installed.flowPath, external)).resolves.toEqual({ - dataDir: await realpath(external), + dataDir: resolve(external), receiptDirectory: join(await realpath(external), 'hosted-extension-receipts'), }); }); + it('preserves lexical daemon identity while canonicalizing receipt storage', async () => { + const installed = await project(); + const external = await mkdtemp(join(tmpdir(), 'hosted-data-target-')); + const aliases = await mkdtemp(join(tmpdir(), 'hosted-data-alias-')); + roots.push(external, aliases); + await symlink(external, join(aliases, 'journal-link')); + const lexicalDataDir = join(aliases, 'journal-link', 'state'); + + const validated = await assertHostedDataDirectoryIsolated( + installed.flowPath, + lexicalDataDir, + ); + expect(validated).toEqual({ + dataDir: resolve(lexicalDataDir), + receiptDirectory: join(await realpath(external), 'state', 'hosted-extension-receipts'), + }); + expect(socketPathFor(validated.dataDir)).toBe(socketPathFor(lexicalDataDir)); + }); + it('refuses a custom in-project directory even when it does not exist', async () => { const installed = await project(); await expect(assertHostedDataDirectoryIsolated( From 36918b878f4b8ae318d05ff43db77decc892dee1 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 1 Oct 2026 09:09:34 -0700 Subject: [PATCH 22/22] fix: refuse symlinked hosted data roots --- packages/sdk/src/hosted-data-directory.ts | 11 +++++++--- .../sdk/tests/hosted-data-directory.test.ts | 21 +++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/sdk/src/hosted-data-directory.ts b/packages/sdk/src/hosted-data-directory.ts index 686177638..b36d17cea 100644 --- a/packages/sdk/src/hosted-data-directory.ts +++ b/packages/sdk/src/hosted-data-directory.ts @@ -32,6 +32,12 @@ export async function assertHostedDataDirectoryIsolated( const requestedDataDir = PATH_RESOLVE(dataDir); const target = await canonicalFuturePath(requestedDataDir); assertOutsideHostedSource(projectRoot, target); + if (requestedDataDir !== target) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted Software Garden data directories must not use symlink path components.', + ); + } // The hosted worker writes its durable provider receipt below this child. // Recheck the derived path so an existing symlink cannot redirect that // write from the excluded data tree back into reviewed project source. @@ -52,9 +58,8 @@ export async function assertHostedDataDirectoryIsolated( } const receipts = await REALPATH(receiptPath); assertInsideDataDirectory(target, receipts); - // Socket identity is intentionally based on the caller's lexical absolute - // path. A running daemon started through a symlink ancestor hashes that same - // spelling, while receipt I/O uses the separately validated canonical path. + // The equality check above makes daemon storage and lexical socket identity + // the same immutable preflight target; receipt I/O uses its validated child. return { dataDir: requestedDataDir, receiptDirectory: receipts }; } diff --git a/packages/sdk/tests/hosted-data-directory.test.ts b/packages/sdk/tests/hosted-data-directory.test.ts index e38b8c75c..c6ffcc1fa 100644 --- a/packages/sdk/tests/hosted-data-directory.test.ts +++ b/packages/sdk/tests/hosted-data-directory.test.ts @@ -1,8 +1,7 @@ import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { socketPathFor } from '../src/daemon-connection.js'; import { assertHostedDataDirectoryIsolated } from '../src/hosted-data-directory.js'; const roots: string[] = []; @@ -25,6 +24,7 @@ describe('hosted data directory isolation', () => { const installed = await project(); const external = await mkdtemp(join(tmpdir(), 'hosted-data-external-')); roots.push(external); + const canonicalExternal = await realpath(external); await expect(assertHostedDataDirectoryIsolated( installed.flowPath, @@ -33,13 +33,13 @@ describe('hosted data directory isolation', () => { dataDir: join(installed.root, '.relayflowd', 'nested'), receiptDirectory: join(installed.root, '.relayflowd', 'nested', 'hosted-extension-receipts'), }); - await expect(assertHostedDataDirectoryIsolated(installed.flowPath, external)).resolves.toEqual({ - dataDir: resolve(external), - receiptDirectory: join(await realpath(external), 'hosted-extension-receipts'), + await expect(assertHostedDataDirectoryIsolated(installed.flowPath, canonicalExternal)).resolves.toEqual({ + dataDir: canonicalExternal, + receiptDirectory: join(canonicalExternal, 'hosted-extension-receipts'), }); }); - it('preserves lexical daemon identity while canonicalizing receipt storage', async () => { + it('refuses a data directory with a symlink ancestor', async () => { const installed = await project(); const external = await mkdtemp(join(tmpdir(), 'hosted-data-target-')); const aliases = await mkdtemp(join(tmpdir(), 'hosted-data-alias-')); @@ -47,15 +47,10 @@ describe('hosted data directory isolation', () => { await symlink(external, join(aliases, 'journal-link')); const lexicalDataDir = join(aliases, 'journal-link', 'state'); - const validated = await assertHostedDataDirectoryIsolated( + await expect(assertHostedDataDirectoryIsolated( installed.flowPath, lexicalDataDir, - ); - expect(validated).toEqual({ - dataDir: resolve(lexicalDataDir), - receiptDirectory: join(await realpath(external), 'state', 'hosted-extension-receipts'), - }); - expect(socketPathFor(validated.dataDir)).toBe(socketPathFor(lexicalDataDir)); + )).rejects.toMatchObject({ code: 'plugin_source_invalid' }); }); it('refuses a custom in-project directory even when it does not exist', async () => {