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
70 changes: 70 additions & 0 deletions docs/BABYSITTER-CATALOG-HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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.

## 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 native handler must consume host-verified delivery authority, normalize the
repository/PR event, and call the Cloud lineage path. Cloud must recheck the
exact live `babysit` label and the bound session/head. Permission declarations
are not enforcement. Export success is byte verification, not execution approval.

## Export reviewed bytes

After the native package is reviewed and committed, build the SDK and run:

```sh
npm run build --prefix packages/surface
npm install ./packages/surface --prefix packages/sdk --no-save --ignore-scripts
npm run build --prefix packages/sdk
node packages/sdk/scripts/export-babysitter-catalog.mjs \
'github:AgentWorkforce/flows@<40sha>#<path>' \
'<reviewed bundle sha256>' '<reviewed flows-plugin.json sha256>' \
/tmp/babysitter-extension.json
```

The two digests come from the independently reviewed package, not from a client
request. The exporter reuses SDK GitHub fetch, manifest validation, file limits,
source checks, and canonical payload hashing. It rejects mutable refs, digest
drift, other repositories, non-Garden compatibility, extra permissions, and
hooks. It neither imports the handler nor overwrites an existing output file.

The output is the existing `FlowExtensionSubmission` shape: `name`, `version`,
`ref`, `digest`, `manifestSha256`, the unmodified JSON manifest, and files with
`path`, `sha256`, `bytes`, `encoding`, `content`. The exact permission declaration
is GitHub, Codex, no MCP, and only `cloud:babysitter-turn` writes.

The immutable source pin is `ref`. Optional manifest source metadata must match
that ref, including `source.sha` if supplied. Do not insert the enclosing commit
SHA into a committed manifest: that would require a Git hash fixed point. Do not
rewrite a manifest after computing its digests. A stronger generated-artifact
source convention must be agreed with the consumer before publication.

## Catalog and Cloud handoff

The catalog owner adds the reviewed output as the sole `babysitter` entry in
Software Factory's `extensions` in
`agentrelay.com/web/data/recommended-flow-catalog.v1.json`. Its exports are
`web/lib/recommended-flow-catalog.ts`, `/api/v1/flows/catalog`, and
`/api/v1/flows/catalog/software-factory`. These are the target paths; this PR
does not insert a placeholder bundle or claim those endpoints already expose it.

The list/detail provenance must cover extension ref and digest along with the
base flow. Cloud checks the same pins and passes the complete entry through
`packages/web/lib/flows/flow-extension-submission.ts`'s
`parseFlowExtensionSubmission`. A valid digest alone is not trusted provenance.

Before enabling, record the actual package version, release/tag (if any), full
source SHA/ref, both digests, catalog commit, and runtime release versions. Human
merge/release precedes catalog publication. Relay native delivery, Cloud lineage,
and RelayHistory receipts must be merged and released before deployment. Capture
the live label-to-original-session receipt before reporting end-to-end readiness.
21 changes: 21 additions & 0 deletions evidence/babysitter-catalog-export/cli-regression.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
$ (cd packages/sdk && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/babysitter-catalog-export.test.ts)

> @relayflows/sdk@2.0.25 build
> tsc && node scripts/make-cli-executable.mjs


> @relayflows/sdk@2.0.25 typecheck:tests
> tsc -p tsconfig.tests.json


RUN v2.1.9 /Users/khaliqgant/Projects/AgentWorkforce/flows/packages/sdk

✓ tests/babysitter-catalog-export.test.ts (14 tests) 2030ms
✓ Babysitter catalog artifact export > CLI refuses an existing output and leaves no file on validation failure 1689ms

Test Files 1 passed (1)
Tests 14 passed (14)
Start at 01:35:33
Duration 4.82s (transform 715ms, setup 0ms, collect 1.25s, tests 2.03s, environment 0ms, prepare 293ms)

Exit: 0
40 changes: 40 additions & 0 deletions evidence/babysitter-catalog-export/verification.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
$ (cd . && npm run build --prefix packages/surface)

> @relayflows/surface@2.0.25 build
> tsc

Exit: 0

$ (cd packages/sdk && npm run typecheck)

> @relayflows/sdk@2.0.25 typecheck
> tsc --noEmit && tsc -p tsconfig.type-tests.json

Exit: 0

$ (cd packages/sdk && npm run typecheck:tests)

> @relayflows/sdk@2.0.25 typecheck:tests
> tsc -p tsconfig.tests.json

Exit: 0

$ (cd packages/sdk && npm run build)

> @relayflows/sdk@2.0.25 build
> tsc && node scripts/make-cli-executable.mjs

Exit: 0

$ (cd packages/sdk && ./node_modules/.bin/vitest run tests/babysitter-catalog-export.test.ts)

RUN v2.1.9 /Users/khaliqgant/Projects/AgentWorkforce/flows/packages/sdk

✓ tests/babysitter-catalog-export.test.ts (13 tests) 358ms

Test Files 1 passed (1)
Tests 13 passed (13)
Start at 01:22:27
Duration 3.14s (transform 759ms, setup 0ms, collect 1.37s, tests 358ms, environment 0ms, prepare 226ms)

Exit: 0
16 changes: 16 additions & 0 deletions packages/sdk/scripts/export-babysitter-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { writeFile } from 'node:fs/promises';
import { exportBabysitterCatalogBundle } from '../dist/babysitter-catalog-export.js';

const [ref, digest, manifestSha256, output, ...extra] = process.argv.slice(2);
try {
if (!ref || !digest || !manifestSha256 || !output || extra.length) {
throw new Error('Usage: node packages/sdk/scripts/export-babysitter-catalog.mjs REF DIGEST MANIFEST_SHA256 OUTPUT.json');
}
const bundle = await exportBabysitterCatalogBundle({ ref, digest, manifestSha256 });
// Never truncate a prior artifact, and never create output on validation failure.
await writeFile(output, JSON.stringify(bundle, null, 2) + '\n', { flag: 'wx' });
process.stdout.write(`Exported ${bundle.name}@${bundle.version} ${bundle.ref} sha256:${bundle.digest}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
44 changes: 44 additions & 0 deletions packages/sdk/src/babysitter-catalog-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { assertBaseCompatible } from './flow-extension-compat.js';
import { resolveExtensionSubmission, type FlowExtensionSubmission } from './flow-extension-submit.js';
import { canonicalPluginRef, parseCanonicalPluginRef } from './plugin-source.js';

export interface BabysitterCatalogPin {
readonly ref: string;
readonly digest: string;
readonly manifestSha256: string;
}

/** Export reviewed bytes, never execute the entry or infer runtime readiness. */
export async function exportBabysitterCatalogBundle(
pin: BabysitterCatalogPin,
options: Parameters<typeof resolveExtensionSubmission>[1] = {},
): Promise<FlowExtensionSubmission> {
const source = parseCanonicalPluginRef(pin.ref);
if (canonicalPluginRef(source) !== pin.ref || source.owner !== 'AgentWorkforce' || source.repo !== 'flows') {
throw new Error('Babysitter catalog source must be a canonical AgentWorkforce/flows commit ref.');
}
if (![pin.digest, pin.manifestSha256].every(value => /^[0-9a-f]{64}$/.test(value))) {
throw new Error('Expected reviewed bundle and manifest SHA-256 digests.');
}
const bundle = await resolveExtensionSubmission(pin.ref, options);
if (bundle.ref !== pin.ref || bundle.digest !== pin.digest || bundle.manifestSha256 !== pin.manifestSha256) {
throw new Error('Babysitter artifact differs from the reviewed pin.');
}
const manifest = bundle.manifest;
if (manifest.name !== 'babysitter') throw new Error('Expected the babysitter extension.');
assertBaseCompatible(manifest, { name: 'software-factory' });
const p = manifest.permissions;
const exactly = (values: readonly string[], expected: string) => values.length === 1 && values[0] === expected;
if (!exactly(p.integrations, 'github') || !exactly(p.harnesses, 'codex') || p.mcp.length !== 0
|| !exactly(p.writes, 'cloud:babysitter-turn')) {
throw new Error('Babysitter requires only github, codex, no MCP, and cloud:babysitter-turn.');
}
if (!manifest.extends.handlers || manifest.extends.hooks.length !== 0) {
throw new Error('Native Babysitter must contribute handlers only, without merge hooks.');
}
// The validator normalizes optional source fields. The wire manifest must
// remain the actual digest-bound JSON so Cloud can independently parse it.
const file = bundle.files.find(entry => entry.path === 'flows-plugin.json')!;
const rawManifest = JSON.parse(Buffer.from(file.content, file.encoding).toString('utf8'));
return { ...bundle, manifest: rawManifest };
}
112 changes: 112 additions & 0 deletions packages/sdk/tests/babysitter-catalog-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { exportBabysitterCatalogBundle } from '../src/babysitter-catalog-export.js';
import { resolveExtensionSubmission } from '../src/flow-extension-submit.js';
import { SHA_A, SHA_B, fakeGithub } from './fake-github.js';

const ref = `github:AgentWorkforce/flows@${SHA_A}#examples/babysitter`;
const versions = { sdk: '2.0.25', surface: '2.0.25' };
const template = JSON.parse(readFileSync(resolve('../../testdata/plugins/extension-babysitter/flows-plugin.json'), 'utf8'));

async function artifact(change: (manifest: typeof template) => void = () => {}) {
const manifest = structuredClone(template);
manifest.permissions = { integrations: ['github'], harnesses: ['codex'], mcp: [], writes: ['cloud:babysitter-turn'] };
change(manifest);
const manifestBytes = JSON.stringify(manifest, null, 2) + '\n';
const github = fakeGithub({ 'AgentWorkforce/flows': { refs: {}, commits: { [SHA_A]: { entries: [
{ path: 'examples/babysitter/flows-plugin.json', data: Buffer.from(manifestBytes) },
// Export must not evaluate an entry, even while verifying its bytes.
{ path: 'examples/babysitter/babysitter.flow.ts', data: Buffer.from('throw new Error("must not execute");') },
{ path: 'examples/babysitter/binary.bin', data: Buffer.from([255, 0, 128]) },
] } } } });
const options = { fetch: github.fetch, versions };
const bundle = await resolveExtensionSubmission(ref, options);
return { manifest, manifestBytes, github, options, pin: { ref, digest: bundle.digest, manifestSha256: bundle.manifestSha256 } };
}

describe('Babysitter catalog artifact export', () => {
it('CLI refuses an existing output and leaves no file on validation failure', async () => {
const a = await artifact();
const directory = mkdtempSync(join(tmpdir(), 'babysitter-export-cli-'));
try {
// Replay only the GitHub responses used by the real resolver. The child
// executes the actual CLI and built exporter; unexpected network is fatal.
const responses: Record<string, { status: number; body: string }> = {};
for (const url of [...new Set(a.github.calls)]) {
const response = await a.github.fetch(url, { headers: {}, signal: new AbortController().signal });
responses[url] = { status: response.status, body: Buffer.from(await response.arrayBuffer()).toString('base64') };
}
const preload = join(directory, 'github.mjs');
writeFileSync(preload, `const responses = ${JSON.stringify(responses)};
globalThis.fetch = async url => {
const response = responses[String(url)];
if (!response) throw new Error('Unexpected network request: ' + url);
return new Response(Buffer.from(response.body, 'base64'), { status: response.status });
};\n`);
const output = join(directory, 'bundle.json');
const invoke = (destination: string, digest = a.pin.digest) => spawnSync(process.execPath, [
'--import', pathToFileURL(preload).href, resolve('scripts/export-babysitter-catalog.mjs'),
ref, digest, a.pin.manifestSha256, destination,
], { encoding: 'utf8', timeout: 15_000 });
const first = invoke(output);
expect(first.status, first.stderr).toBe(0);
const original = readFileSync(output);
expect(JSON.parse(original.toString())).toMatchObject(a.pin);
const duplicate = invoke(output);
expect(duplicate.status, duplicate.stderr).toBe(1);
expect(duplicate.stderr).toContain('EEXIST');
expect(duplicate.stdout).toBe('');
expect(readFileSync(output)).toEqual(original);
const invalidOutput = join(directory, 'invalid.json');
const invalid = invoke(invalidOutput, '0'.repeat(64));
expect(invalid.status, invalid.stderr).toBe(1);
expect(invalid.stderr).toContain('differs from the reviewed pin');
expect(invalid.stdout).toBe('');
expect(existsSync(invalidOutput)).toBe(false);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});

it('preserves digest-bound manifest and binary bytes without executing code', async () => {
const a = await artifact(m => { m.source = { host: 'github', owner: 'AgentWorkforce', repo: 'flows', path: 'examples/babysitter' }; });
const exported = await exportBabysitterCatalogBundle(a.pin, a.options);
expect(exported.manifest).toEqual(a.manifest);
expect(exported.files.find(f => f.path === 'flows-plugin.json')?.content).toBe(a.manifestBytes);
expect(exported.files.find(f => f.path === 'binary.bin')).toMatchObject({ encoding: 'base64', content: '/wCA', bytes: 3 });
expect(exported).toMatchObject(a.pin);
});

it.each(['main', 'v1.0.0', SHA_A.slice(0, 12)])('rejects mutable/noncanonical ref %s before fetching', async sha => {
const a = await artifact();
a.github.calls.length = 0;
await expect(exportBabysitterCatalogBundle({ ...a.pin, ref: ref.replace(SHA_A, sha) }, a.options)).rejects.toThrow();
expect(a.github.calls).toEqual([]);
});

it.each(['digest', 'manifestSha256'] as const)('rejects a changed %s', async key => {
const a = await artifact();
await expect(exportBabysitterCatalogBundle({ ...a.pin, [key]: '0'.repeat(64) }, a.options)).rejects.toThrow('differs from the reviewed pin');
});

it.each([
(m: typeof template) => { m.permissions.harnesses = ['claude']; },
(m: typeof template) => { m.permissions.writes.push('github:pull_request:merge'); },
(m: typeof template) => { m.permissions.mcp = ['shell']; },
(m: typeof template) => { m.extends.hooks = ['merge-gate']; },
(m: typeof template) => { m.compat.base = [{ name: 'other', version: '*' }]; },
(m: typeof template) => { m.name = 'other'; },
])('rejects overbroad or unrelated manifests', async change => {
const a = await artifact(change);
await expect(exportBabysitterCatalogBundle(a.pin, a.options)).rejects.toThrow();
});

it('rejects self-declared source drift', async () => {
await expect(artifact(m => { m.source = { host: 'github', owner: 'AgentWorkforce', repo: 'flows', path: 'examples/babysitter', sha: SHA_B }; }))
.rejects.toThrow('declares source');
});
});
Loading