diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md new file mode 100644 index 000000000..af79b42ca --- /dev/null +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -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>#' \ + '' '' \ + /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. diff --git a/evidence/babysitter-catalog-export/cli-regression.txt b/evidence/babysitter-catalog-export/cli-regression.txt new file mode 100644 index 000000000..c1aecbcc6 --- /dev/null +++ b/evidence/babysitter-catalog-export/cli-regression.txt @@ -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 diff --git a/evidence/babysitter-catalog-export/verification.txt b/evidence/babysitter-catalog-export/verification.txt new file mode 100644 index 000000000..fb6b6fad7 --- /dev/null +++ b/evidence/babysitter-catalog-export/verification.txt @@ -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 diff --git a/packages/sdk/scripts/export-babysitter-catalog.mjs b/packages/sdk/scripts/export-babysitter-catalog.mjs new file mode 100644 index 000000000..4ed7c8571 --- /dev/null +++ b/packages/sdk/scripts/export-babysitter-catalog.mjs @@ -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; +} diff --git a/packages/sdk/src/babysitter-catalog-export.ts b/packages/sdk/src/babysitter-catalog-export.ts new file mode 100644 index 000000000..2b71a5027 --- /dev/null +++ b/packages/sdk/src/babysitter-catalog-export.ts @@ -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[1] = {}, +): Promise { + 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 }; +} diff --git a/packages/sdk/tests/babysitter-catalog-export.test.ts b/packages/sdk/tests/babysitter-catalog-export.test.ts new file mode 100644 index 000000000..10a759f10 --- /dev/null +++ b/packages/sdk/tests/babysitter-catalog-export.test.ts @@ -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 = {}; + 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'); + }); +});