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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 31 additions & 16 deletions docs/BABYSITTER-CATALOG-HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
40 changes: 38 additions & 2 deletions packages/sdk/src/hosted-extension-isolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
assertHostedInstallationAuthority,
assertHostedRuntimeAuthority,
loadHostedExtensionRuntime,
type HostedExtensionArtifact,
type HostedExtensionBase,
type HostedExtensionInstallation,
Expand Down Expand Up @@ -104,8 +105,42 @@ export interface RunHostedExtensionOptions {
readonly prlimitPath?: string;
}

export interface RunHostedSoftwareGardenBabysitterOptions
extends Omit<RunHostedExtensionOptions, 'installation' | 'base'> {
/** 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<HostedExtensionResult> {
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
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type { StepSpend } from './protocol.js';
export { SPEC_SCHEMA_VERSION } from './spec.js';

export {
runHostedSoftwareGardenBabysitter,
runHostedCapabilityExtension,
loadHostedExtensionRuntime,
type HostedExtensionArtifact,
Expand All @@ -70,6 +71,7 @@ export {
type HostedCapabilityAuthority,
type HostedBabysitterCapability,
type RunHostedExtensionOptions,
type RunHostedSoftwareGardenBabysitterOptions,
type HostedExtensionResult,
} from './hosted-extension-isolation.js';
export {
Expand Down
148 changes: 148 additions & 0 deletions packages/sdk/tests/software-garden-babysitter-composition.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof project>>;
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);
},
);
});
Loading