diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 599920f2..414493cb 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -1,17 +1,24 @@ # Babysitter catalog artifact handoff -Babysitter is an optional extension on Software Factory/Garden, never a second -Recommended Flow. Activation accepts only top-level `babysitter: { enabled: -boolean }`; Cloud reserves the extension name and obtains its bytes from the -server-owned catalog. Client extension bytes cannot enable Babysitter. +Babysitter is a first-class Recommended Flow of kind `extension`, related to +the `software-factory` base. Cloud reserves the extension name and obtains its +bytes from the server-owned catalog. Client extension bytes cannot enable +Babysitter. Discovery does not imply activation: activation stays blocked until +the hosted caller uses the canonical composition entrypoint and a live receipt +proves the complete path. ## Current readiness -No native Babysitter artifact is released by this change. PR #549 merged the -authenticated dispatch boundary but intentionally refuses matched extension -handlers with `plugin_unsupported`. The existing `examples/babysitter` declares -Claude, GitHub comment writes, and a merge-gate hook; it is not the native -existing-session package. Keep enabled activation at 409 with zero writes. +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. The native handler must consume host-verified delivery authority, normalize the repository/PR event, and call the Cloud lineage path. Cloud must recheck the @@ -20,11 +27,12 @@ are not enforcement. Export success is byte verification, not execution approval The native package source is `extensions/babysitter` (see its README for the turn contract). Flows 2.0.26 is published, but the package cannot execute -through the generic executor: #549 still refuses it. The SDK now has a separate Linux-only +through the generic executor: #549 still refuses it. The SDK has a separate Linux-only capability sandbox that injects exactly `capabilities.cloud.babysitterTurn.queue` without exposing the base context, workspace, environment credentials, network, helpers, MCP, or harnesses. It is -not wired to hosted dispatch and must not be treated as enablement. The package's +reached by the canonical composition entrypoint, but no deployed runtime calls +that entrypoint 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 @@ -51,11 +59,18 @@ 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 #3942 owns the -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: 'queued' | 'duplicate' }`. Refusal or in-doubt transport -rejects once with no fallback. +lineage, label, head, prompt, merge, route, or config authority. Cloud PR #4002 +at `25412782bf148ff8dd8018bdbafd719d4e8347fa` 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` +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: +'queued' | 'duplicate' }`. Refusal or in-doubt transport rejects once with no +fallback. Activation remains blocked until the external runtime owner is +merged, deployed, and the complete path produces a live receipt on a private +repository. Only the parent validator is a security boundary. The isolated entry can write its inherited protocol descriptor directly and bypass child-side routing, diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 5e48a2f4..560a99f0 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -10,6 +10,7 @@ import { import { assertHostedInstallationAuthority, assertHostedRuntimeAuthority, + loadHostedExtensionRuntime, type HostedExtensionArtifact, type HostedExtensionBase, type HostedExtensionInstallation, @@ -104,8 +105,42 @@ export interface RunHostedExtensionOptions { readonly prlimitPath?: string; } +export interface RunHostedSoftwareGardenBabysitterOptions + extends Omit { + /** Exact reviewed Software Garden source inside its lock-backed project. */ + readonly flowPath: string; +} + export type HostedExtensionResult = HostedExtensionProtocolResult; +/** + * Compose the canonical Software Garden base with its installed native + * Babysitter and execute one host-verified delivery. Base source, extension + * declarations, lock entries and stored bytes are captured as one opaque + * generation; the capability runner rechecks that generation immediately + * before the isolated process starts. + * + * Hosted callers use this entrypoint instead of the ordinary authored loader, + * which imports extension JavaScript in the host and therefore keeps refusing + * matched hosted handlers. + */ +export async function runHostedSoftwareGardenBabysitter( + options: RunHostedSoftwareGardenBabysitterOptions, +): Promise { + const runtime = await loadHostedExtensionRuntime(options.flowPath); + return await runHostedCapabilityExtension({ + installation: runtime.installation, + base: runtime.base, + dispatch: options.dispatch, + input: options.input, + babysitterTurn: options.babysitterTurn, + ...(OBJECT_HAS_OWN(options, 'timeoutMs') ? { timeoutMs: options.timeoutMs } : {}), + ...(OBJECT_HAS_OWN(options, 'bubblewrapPath') ? { bubblewrapPath: options.bubblewrapPath } : {}), + ...(OBJECT_HAS_OWN(options, 'nodePath') ? { nodePath: options.nodePath } : {}), + ...(OBJECT_HAS_OWN(options, 'prlimitPath') ? { prlimitPath: options.prlimitPath } : {}), + }); +} + /** * Execute a capability-only hosted extension in a Linux mount/PID/network/user * namespace. The extension is first imported inside that namespace. It sees @@ -114,8 +149,9 @@ export type HostedExtensionResult = HostedExtensionProtocolResult; * authenticated capability adapter and passes the original branded dispatch * authority to that adapter out of band. * - * This is deliberately not wired into executeAuthoredFlow yet. #549's refusal - * remains the rollout gate until the Cloud adapter and independent review land. + * The ordinary authored executor deliberately stays refused: it imports + * extension JavaScript in the host. `runHostedSoftwareGardenBabysitter` is the + * canonical composition boundary for a hosted Software Garden delivery. */ export async function runHostedCapabilityExtension( options: RunHostedExtensionOptions, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 9b1c2c3c..5ea64176 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -61,6 +61,7 @@ export type { StepSpend } from './protocol.js'; export { SPEC_SCHEMA_VERSION } from './spec.js'; export { + runHostedSoftwareGardenBabysitter, runHostedCapabilityExtension, loadHostedExtensionRuntime, type HostedExtensionArtifact, @@ -70,6 +71,7 @@ export { type HostedCapabilityAuthority, type HostedBabysitterCapability, type RunHostedExtensionOptions, + type RunHostedSoftwareGardenBabysitterOptions, type HostedExtensionResult, } from './hosted-extension-isolation.js'; export { diff --git a/packages/sdk/tests/software-garden-babysitter-composition.test.ts b/packages/sdk/tests/software-garden-babysitter-composition.test.ts new file mode 100644 index 00000000..b2becf0b --- /dev/null +++ b/packages/sdk/tests/software-garden-babysitter-composition.test.ts @@ -0,0 +1,148 @@ +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { addExtensionPlugin } from '../src/cli/add-extension.js'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; +import { runHostedSoftwareGardenBabysitter } from '../src/hosted-extension-isolation.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-babysitter-')); + roots.push(root); + writeFileSync(join(root, 'software-factory.flow.ts'), 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: join(root, 'software-factory.flow.ts') }; +} + +function dispatch(eventType = 'pull_request.labeled', deliveryId = 'delivery-1') { + return hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType, deliveryId }); +} + +function input(eventType = 'pull_request.labeled', deliveryId = 'delivery-1') { + return { + event: { provider: 'github', eventType, deliveryId }, + pullRequest: { host: 'github', owner: 'AgentWorkforce', repo: 'flows', number: 584, headSha: 'a'.repeat(40) }, + }; +} + +describe('canonical Software Garden + Babysitter composition', () => { + let installed: Awaited>; + beforeAll(async () => { installed = await project(); }); + + it('installs the exact reviewed native artifact as the only composition member', () => { + const lock = JSON.parse(readFileSync(join(installed.root, 'flows.lock.json'), 'utf8')) as { + plugins: Array<{ source: { sha: string; path: string }; digest: string; manifestSha256: string }>; + }; + expect(lock.plugins).toEqual([expect.objectContaining({ + source: expect.objectContaining({ sha: NATIVE_SHA, path: 'extensions/babysitter' }), + digest: DIGEST, + manifestSha256: MANIFEST_SHA256, + })]); + }); + + it.runIf(process.platform === 'linux')('fails closed when the canonical Garden has no installed Babysitter artifact', async () => { + const empty = await project(false); + await expect(runHostedSoftwareGardenBabysitter({ + flowPath: empty.flowPath, + dispatch: dispatch(), + input: input(), + babysitterTurn: { queue: async () => ({ receiptId: 'never', status: 'queued' }) }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + }); + + it.runIf(process.platform === 'linux')('fails closed before the capability when the installed store digest drifts', async () => { + const changed = await project(); + appendFileSync(join(changed.root, '.flows/plugins', `babysitter@sha256:${DIGEST}`, 'turn.ts'), '\n// drift\n'); + let calls = 0; + await expect(runHostedSoftwareGardenBabysitter({ + flowPath: changed.flowPath, + dispatch: dispatch(), + input: input(), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_source_drift' }); + expect(calls).toBe(0); + }); + + it.runIf(process.platform === 'linux')('fails closed before the capability when no reviewed handler owns the delivery', async () => { + let calls = 0; + await expect(runHostedSoftwareGardenBabysitter({ + flowPath: installed.flowPath, + dispatch: dispatch('push'), + input: input('push'), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + expect(calls).toBe(0); + }); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'executes the pinned composition and preserves queued/duplicate replay receipts', async () => { + const statuses = ['queued', 'duplicate'] as const; + const deliveryId = 'delivery-replay'; + const receiptId = `bst_${'1'.repeat(64)}`; + const replayDispatch = dispatch('pull_request.labeled', deliveryId); + const replayInput = input('pull_request.labeled', deliveryId); + const calls: unknown[] = []; + for (let index = 0; index < statuses.length; index += 1) { + const result = await runHostedSoftwareGardenBabysitter({ + flowPath: installed.flowPath, + dispatch: replayDispatch, + input: replayInput, + babysitterTurn: { queue: async (request, authority) => { + calls.push({ request, authority }); + return { receiptId, status: statuses[index] }; + } }, + }); + expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } + expect(calls).toHaveLength(2); + expect(calls.map(call => (call as { request: { delivery: { deliveryId: string } } }).request.delivery.deliveryId)) + .toEqual([deliveryId, deliveryId]); + 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 }, + ]); + }, + ); + + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'propagates capability denial without a retry or fallback', async () => { + const refusal = new Error('live babysit label is absent'); + let calls = 0; + await expect(runHostedSoftwareGardenBabysitter({ + flowPath: installed.flowPath, + dispatch: dispatch(), + input: input(), + babysitterTurn: { queue: async () => { calls += 1; throw refusal; } }, + })).rejects.toBe(refusal); + expect(calls).toBe(1); + }, + ); +});