diff --git a/web/content/docs/relayflows/plugins.mdx b/web/content/docs/relayflows/plugins.mdx index f371ed9..c4dc36e 100644 --- a/web/content/docs/relayflows/plugins.mdx +++ b/web/content/docs/relayflows/plugins.mdx @@ -118,7 +118,7 @@ The gallery and the deploy wizard display a tier. **The label is never used to s | **verified** | bundle `identity.json` keyid matches a publisher key registered in Cloud | | **community** | anything else | -Babysitter in catalog v3 is **first-party**: its reviewed `v2.0.26` artifact commit is reachable from `AgentWorkforce/flows` main, and the hosted runtime provenance is the published SDK `v2.0.31` release. The label does not widen runtime authority. +Babysitter in plugin catalog v3 and Recommended catalog v4 is **first-party**: its reviewed `v2.0.26` artifact commit is reachable from `AgentWorkforce/flows` main, and the hosted runtime provenance is the published SDK `v2.0.31` release. The Recommended entry makes the blocked extension discoverable alongside Software Garden; the label does not widen runtime authority. `permissions.writes` stays a reviewed declaration labelled UNENFORCED until gate 8. Displaying a first-party or verified badge does not enforce write scope, skip the digest, or route an event the registry does not carry. @@ -128,7 +128,7 @@ Babysitter in catalog v3 is **first-party**: its reviewed `v2.0.26` artifact com `flowPluginBadgeMarkdown()` renders the README form for an extension whose activation gate is ready. `plugin` is appended so repeats survive. -Babysitter's gate requires both `cloud-babysitter-capability-adapter` and `relay-native-existing-session-delivery` to carry a pull request, merge commit, merge time, deployment receipt, and deployment time. Until both proofs are recorded and the gate state is `ready`, `flowPluginInstallHref()` returns `null` and the gallery shows catalog status plus source only. +Babysitter's gate requires `cloud-babysitter-capability-adapter`, `relay-hosted-flow-extension-execution`, and `relay-native-existing-session-delivery` to carry a pull request, merge commit, merge time, deployment receipt, and deployment time. It also requires `liveProof`: an immutable, digest-addressed JSON record from a full `babysit` label-to-existing-session-turn-to-receipt run. The proof pins the pull request, live head, exact label, observation time, and receipt ID, and must postdate every dependency deployment without being future-dated. Until all deployment proofs and that integrated proof are recorded and the gate state is `ready`, `flowPluginInstallHref()` returns `null` and the gallery shows catalog status plus source only. The catalog itself is versioned JSON, `{version:3, plugins:[{name, description, source:{owner,repo,path}, ref, digest, manifestSha256, compat, runtime, activation, tier, base}]}`. Any public repo with a `flows-plugin.json` remains installable by an explicit source URL; the curated catalog does not advertise an activation route until its dependency gate is satisfied. @@ -137,8 +137,13 @@ The code-owned implementation contract binds `cloud-babysitter-capability-adapte to [Cloud PR #3989](https://github.com/AgentWorkforce/cloud/pull/3989) and `relay-native-existing-session-delivery` to [Relay PR #1851](https://github.com/AgentWorkforce/relay/pull/1851). Unrelated PRs -in those repositories cannot satisfy either dependency. A replacement implementation +in those repositories cannot satisfy either registered dependency. A replacement implementation requires a reviewed code change to this contract. +`relay-hosted-flow-extension-execution` deliberately has no accepted implementation +PR yet. Cloud PR #4002 is a consumer contract, not execution authority, and cannot +satisfy this dependency. Activation therefore remains impossible until the assigned +Relay/Flows owner opens a focused implementation PR and a reviewed catalog change +registers that exact PR before deployment evidence can be admitted. `pullRequestUrl` must identify a merged PR into the dependency repository's `main`, with the exact `mergedCommit` and `mergedAt`. `deploymentUrl` must be the canonical GitHub API deployment URL (`https://api.github.com/repos/OWNER/REPO/deployments/ID`). diff --git a/web/content/docs/relayflows/recommended.mdx b/web/content/docs/relayflows/recommended.mdx index 6c45e21..e4f33af 100644 --- a/web/content/docs/relayflows/recommended.mdx +++ b/web/content/docs/relayflows/recommended.mdx @@ -9,9 +9,15 @@ Recommended flows are maintained starting points. The catalog is public, version Software Garden is the display name of the first recommended flow. Its stable catalog and authored flow ID is `software-factory`: a GitHub issue starts implementation, deterministic repository checks, adversarial review, and a pull request for a human decision. -The released source uses Claude Code for implementation and review, so catalog version 3 allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow. +The released source uses Claude Code for implementation and review, so the Software Garden entry allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow. -Software Garden currently supports GitHub repositories. Deploy one listener per repository using the direct-source API below. Babysitter remains a [flow plugin](/docs/relayflows/plugins), not a recommended flow of its own. The catalog carries its immutable artifact and Relayflows SDK 2.0.31 runtime provenance as inert extension metadata; activation stays blocked until the Cloud capability adapter and Relay native delivery both carry merge and deployment evidence. +Software Garden currently supports GitHub repositories. Deploy one listener per repository using the direct-source API below. + +## Babysitter + +Babysitter is a first-class Recommended entry so its purpose and readiness are visible. It is a [flow plugin](/docs/relayflows/plugins) that extends Software Garden, not a standalone listener: its `kind` is `extension` and its `baseFlowId` is `software-factory`. + +The Babysitter detail response carries the exact reviewed extension bundle, immutable artifact digest, manifest digest, and Relayflows SDK 2.0.31 runtime provenance. Discovery does not imply availability. Activation remains blocked, and no activation link is advertised, until the Cloud production host and Relay native existing-session delivery carry exact merge and deployment evidence and the integrated label-to-turn-to-receipt path has an immutable, digest-addressed `liveProof` record. ## Catalog API @@ -20,9 +26,10 @@ The stable endpoints are: ```text GET https://agentrelay.com/api/v1/flows/catalog GET https://agentrelay.com/api/v1/flows/catalog/software-factory +GET https://agentrelay.com/api/v1/flows/catalog/babysitter ``` -The list response starts with `schemaVersion: 1` and `catalogVersion: 3`. Every flow has a stable `id`, its own numeric `version`, display copy, repository-host support, trigger defaults, required/default activation inputs, and an immutable source reference. Optional extension entries are metadata only unless their activation gate is `ready` and every declared dependency has merge and deployment evidence. +The list response starts with `schemaVersion: 1` and `catalogVersion: 4`. Every entry has a stable `id`, its own numeric `version`, display copy, repository-host support, and an immutable source reference. Flow entries carry trigger defaults and activation inputs. Extension entries instead name their base flow and plugin contract. An extension is not activatable unless its gate is `ready` and every declared dependency has merge and deployment evidence. The catalog does not copy or generate the flow body. `source` names the canonical `AgentWorkforce/flows` owner, repository, path, release tag, full commit SHA, GitHub blob and raw URLs, media type, and SHA-256 content digest. Both URLs contain the commit SHA, never a mutable branch: diff --git a/web/data/babysitter-extension.v1.json b/web/data/babysitter-extension.v1.json new file mode 100644 index 0000000..6cc22ab --- /dev/null +++ b/web/data/babysitter-extension.v1.json @@ -0,0 +1,116 @@ +{ + "name": "babysitter", + "version": "0.2.0", + "ref": "github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter", + "digest": "bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee", + "manifestSha256": "5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226", + "manifest": { + "schema": 2, + "kind": "flow-extension", + "name": "babysitter", + "version": "0.2.0", + "description": "Native Babysitter for Software Factory: turns a verified PR delivery into one cloud:babysitter-turn request. Cloud rechecks the live babysit label, head, and bound session; this extension holds no GitHub write authority.", + "compat": { + "surface": "^2.0.26", + "sdk": "^2.0.26", + "base": [ + { + "name": "software-factory", + "version": "*" + } + ] + }, + "entry": "babysitter.flow.ts", + "extends": { + "handlers": true, + "hooks": [] + }, + "triggers": [ + { + "provider": "github", + "event": "pull_request", + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review", + "closed", + "labeled", + "unlabeled" + ] + }, + { + "provider": "github", + "event": "pull_request_review", + "actions": [ + "submitted", + "dismissed" + ] + }, + { + "provider": "github", + "event": "check_run", + "actions": [ + "completed" + ] + }, + { + "provider": "github", + "event": "issue_comment", + "actions": [ + "created" + ] + } + ], + "permissions": { + "integrations": [ + "github" + ], + "harnesses": [ + "codex" + ], + "mcp": [], + "writes": [ + "cloud:babysitter-turn" + ], + "budget": { + "dollars": 1, + "wallclock": "5m" + } + }, + "preflight": { + "credentials": [], + "servers": [] + } + }, + "files": [ + { + "path": "README.md", + "sha256": "70b2b79c8f2d9837b3bf373bae3588a4809c7376c6c66fda3587ef6eaa8f1467", + "bytes": 2369, + "encoding": "utf8", + "content": "# Native Babysitter (flow extension)\n\nA schema-2 extension on `software-factory`. Each of its eleven GitHub\nsubscriptions takes Cloud's normalized delivery descriptor and makes one\n`cloud:babysitter-turn` queue request:\n\n```jsonc\n// in: Cloud's normalized descriptor, never a raw webhook\n{ \"event\": { \"provider\": \"github\", \"eventType\": \"pull_request.labeled\", \"deliveryId\": \"…\" },\n \"pullRequest\": { \"host\": \"github\", \"owner\": \"…\", \"repo\": \"…\", \"number\": 1, \"headSha\": \"<40 hex, optional>\" } }\n// out: f.capabilities.cloud.babysitterTurn.queue(request)\n{ \"delivery\": { \"deliveryId\": \"…\", \"provider\": \"github\", \"eventType\": \"pull_request.labeled\",\n \"pullRequest\": { \"owner\": \"…\", \"repository\": \"…\", \"number\": 1 } } }\n```\n\nThe delivery is an envelope, not authority. The handler never sends findings,\nprose, a head, label, session, lineage, relay agent, config, merge flag, or\nroute; Cloud builds the prompt from the live head and the trusted binding. Cloud's\ncapability adapter checks the envelope against the host-verified dispatch,\nloads the persisted activation, rereads the live PR (open, exact `babysit`\nlabel, head), and resolves the one bound Codex session. It answers\n`{ receiptId, status: queued | duplicate }` and the run completes `success`;\nCloud dedupes by lineage and live head, so `deliveryId` is provenance, not the\nidempotency key. Every policy refusal and in-doubt transport failure rejects,\nand the run fails once with no retry or fallback. Any other input or receipt,\nor a runtime without the capability, fails the run.\n\n## What this does not do\n\n- Hosted execution is still refused. The SDK rejects every matched extension\n handler with `plugin_unsupported` until manifest permissions are enforced\n (#549, gate 8 / #442). This package does not change that.\n- The SDK context has no `capabilities.cloud.babysitterTurn`, and Cloud's\n adapter is not merged. Both must land before a turn can happen.\n- It is not the legacy `examples/babysitter` (Claude review lenses, GitHub\n comments, merge-gate hook). It holds no GitHub write authority.\n- `compat` needs a surface release after 2.0.25: the published 2.0.25 event\n registry cannot route `labeled`, `unlabeled`, or `ready_for_review`.\n\nCatalog export follows `docs/BABYSITTER-CATALOG-HANDOFF.md`, with\n`#extensions/babysitter` as the path, after human review and merge.\n" + }, + { + "path": "babysitter.flow.ts", + "sha256": "e1e8e9690334360612a6f6e749746e6a322eefea922f61342479c3bac18490b6", + "bytes": 3070, + "encoding": "utf8", + "content": "// Native Babysitter: an extension on Software Factory whose only effect is a\n// `cloud:babysitter-turn` request for the PR a verified delivery names. The\n// review itself happens in the original Codex session Cloud has bound to that\n// PR; this entry holds no GitHub credentials, runs no agent, and writes nothing\n// to GitHub. Hosted execution stays refused by the SDK (#549, gate 8 / #442).\nimport { flow, github, type Ctx, type TriggerSource } from '@relayflows/surface';\nimport { turnDelivery, turnReceipt, type BabysitterTurnDelivery, type Subscription } from './turn.ts';\n\ninterface TurnQueue {\n queue(request: { readonly delivery: BabysitterTurnDelivery }): PromiseLike;\n}\n\n/**\n * The declared write, as Cloud's capability adapter spells it. The SDK `Ctx`\n * has no `capabilities` field yet; a runtime without it must fail the run,\n * never skip the turn silently. The adapter, not this entry, holds workspace,\n * activation, and the verified delivery authority.\n */\nfunction turnQueue(f: Ctx): TurnQueue {\n const capabilities = (f as unknown as { readonly capabilities?: { readonly cloud?: { readonly babysitterTurn?: unknown } } }).capabilities;\n const port = capabilities?.cloud?.babysitterTurn;\n if (typeof port !== 'object' || port === null || typeof (port as Partial).queue !== 'function') {\n throw new Error('babysitter: this runtime does not provide the cloud:babysitter-turn write.');\n }\n return port as TurnQueue;\n}\n\nfunction handler(subscription: Subscription) {\n return async (f: Ctx, input: unknown): Promise => {\n const delivery = turnDelivery(subscription, input);\n // A delivery-only wake: Cloud builds the prompt from the live head and the\n // trusted binding, so this entry sends no findings or prose. Refusals reject.\n turnReceipt(await turnQueue(f).queue({ delivery }));\n f.done('success');\n };\n}\n\nconst handlers: readonly (readonly [TriggerSource, Subscription])[] = [\n [github.pull_request('opened'), 'pull_request.opened'],\n [github.pull_request('synchronize'), 'pull_request.synchronize'],\n [github.pull_request('reopened'), 'pull_request.reopened'],\n [github.pull_request('ready_for_review'), 'pull_request.ready_for_review'],\n [github.pull_request('closed'), 'pull_request.closed'],\n [github.pull_request('labeled'), 'pull_request.labeled'],\n [github.pull_request('unlabeled'), 'pull_request.unlabeled'],\n [github.pull_request_review({ action: 'submitted' }), 'pull_request_review.submitted'],\n [github.pull_request_review({ action: 'dismissed' }), 'pull_request_review.dismissed'],\n [github.check_run('completed'), 'check_run.completed'],\n [github.issue_comment('created'), 'issue_comment.created'],\n];\n\n// The default body is reachable only by a direct run, whose input is\n// caller-controlled JSON. It never requests a turn.\nexport default handlers.reduce>(\n (handle, [trigger, subscription]) => handle.on(trigger, handler(subscription)),\n flow('babysitter', { budget: { dollars: 1, wallclock: '5m' } }, async f => { f.done('declined'); }),\n);\n" + }, + { + "path": "flows-plugin.json", + "sha256": "5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226", + "bytes": 1223, + "encoding": "utf8", + "content": "{\n \"schema\": 2,\n \"kind\": \"flow-extension\",\n \"name\": \"babysitter\",\n \"version\": \"0.2.0\",\n \"description\": \"Native Babysitter for Software Factory: turns a verified PR delivery into one cloud:babysitter-turn request. Cloud rechecks the live babysit label, head, and bound session; this extension holds no GitHub write authority.\",\n \"compat\": {\n \"surface\": \"^2.0.26\",\n \"sdk\": \"^2.0.26\",\n \"base\": [{ \"name\": \"software-factory\", \"version\": \"*\" }]\n },\n \"entry\": \"babysitter.flow.ts\",\n \"extends\": { \"handlers\": true, \"hooks\": [] },\n \"triggers\": [\n { \"provider\": \"github\", \"event\": \"pull_request\", \"actions\": [\"opened\", \"synchronize\", \"reopened\", \"ready_for_review\", \"closed\", \"labeled\", \"unlabeled\"] },\n { \"provider\": \"github\", \"event\": \"pull_request_review\", \"actions\": [\"submitted\", \"dismissed\"] },\n { \"provider\": \"github\", \"event\": \"check_run\", \"actions\": [\"completed\"] },\n { \"provider\": \"github\", \"event\": \"issue_comment\", \"actions\": [\"created\"] }\n ],\n \"permissions\": {\n \"integrations\": [\"github\"],\n \"harnesses\": [\"codex\"],\n \"mcp\": [],\n \"writes\": [\"cloud:babysitter-turn\"],\n \"budget\": { \"dollars\": 1, \"wallclock\": \"5m\" }\n },\n \"preflight\": { \"credentials\": [], \"servers\": [] }\n}\n" + }, + { + "path": "turn.ts", + "sha256": "a5189b3b4db9cb7f4812e480f29dc33dfb5b392ff284952cefac9b5f8a18f781", + "bytes": 5033, + "encoding": "utf8", + "content": "/**\n * The native Babysitter turn contract: Cloud's normalized delivery descriptor\n * in, one `cloud:babysitter-turn` queue request out.\n *\n * Nothing here is authority. The delivery is an envelope: Cloud's capability\n * adapter checks it against the host-verified dispatch, loads the persisted\n * activation, rereads the live PR (open, exact `babysit` label, head), and\n * resolves the bound session and lineage. The handler never names a session,\n * lineage, head, label, route, or config, so a forged or stale descriptor can\n * at most ask Cloud to look again.\n */\n\n/** The subscriptions flows-plugin.json declares, as `event.action` event types. */\nexport const SUBSCRIPTIONS = [\n 'pull_request.opened', 'pull_request.synchronize', 'pull_request.reopened',\n 'pull_request.ready_for_review', 'pull_request.closed', 'pull_request.labeled',\n 'pull_request.unlabeled', 'pull_request_review.submitted', 'pull_request_review.dismissed',\n 'check_run.completed', 'issue_comment.created',\n] as const;\nexport type Subscription = (typeof SUBSCRIPTIONS)[number];\n\nexport interface BabysitterTurnDelivery {\n readonly deliveryId: string;\n readonly provider: 'github';\n readonly eventType: Subscription;\n readonly pullRequest: { readonly owner: string; readonly repository: string; readonly number: number };\n}\n\n/**\n * `queued`: Cloud wrote its head-bound receipt and Relay accepted the turn.\n * `duplicate`: that lineage and live head were already queued. Every refusal\n * and every in-doubt transport failure rejects instead of resolving.\n */\nexport interface BabysitterTurnReceipt {\n readonly receiptId: string;\n readonly status: 'queued' | 'duplicate';\n}\n\nconst DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/;\nconst OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;\nconst REPO = /^[A-Za-z0-9._-]{1,100}$/;\nconst SHA = /^[0-9a-f]{40}$/;\nconst RECEIPT = /^[A-Za-z0-9_.:-]{1,200}$/;\n\nfunction refuse(message: string): never {\n throw new Error(`babysitter: ${message}`);\n}\n\nfunction record(value: unknown, what: string, keys: readonly string[], required: readonly string[]): Record {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) refuse(`${what} must be an object.`);\n const object = value as Record;\n const extra = Object.keys(object).filter(key => !keys.includes(key));\n if (extra.length > 0) refuse(`${what} has unexpected fields ${extra.join(', ')}.`);\n const missing = required.filter(key => !Object.hasOwn(object, key));\n if (missing.length > 0) refuse(`${what} is missing ${missing.join(', ')}.`);\n return object;\n}\n\nfunction matches(value: unknown, pattern: RegExp, what: string): string {\n if (typeof value !== 'string' || !pattern.test(value)) refuse(`${what} is malformed.`);\n return value;\n}\n\n/**\n * Validate the descriptor a handler received against the subscription that\n * handler was registered for. Any drift, unknown field, or raw webhook shape\n * is refused rather than guessed at. Cloud's `headSha` enrichment is checked\n * but not forwarded: Cloud binds to the head it rereads, never to a hint.\n */\nexport function turnDelivery(subscription: Subscription, input: unknown): BabysitterTurnDelivery {\n const top = record(input, 'input', ['event', 'pullRequest'], ['event', 'pullRequest']);\n const event = record(top.event, 'event', ['provider', 'eventType', 'deliveryId'], ['provider', 'eventType', 'deliveryId']);\n const pr = record(top.pullRequest, 'pullRequest', ['host', 'owner', 'repo', 'number', 'headSha'], ['owner', 'repo', 'number']);\n if (event.provider !== 'github') refuse('event.provider must be github.');\n if (event.eventType !== subscription) refuse(`event ${JSON.stringify(event.eventType)} was delivered to the ${subscription} handler.`);\n if (pr.host !== undefined && pr.host !== 'github') refuse('pullRequest.host must be github.');\n const owner = matches(pr.owner, OWNER, 'pullRequest.owner');\n const repository = matches(pr.repo, REPO, 'pullRequest.repo');\n if (repository === '.' || repository === '..') refuse('pullRequest.repo is malformed.');\n if (typeof pr.number !== 'number' || !Number.isSafeInteger(pr.number) || pr.number <= 0) refuse('pullRequest.number must be a positive integer.');\n if (pr.headSha !== undefined) matches(pr.headSha, SHA, 'pullRequest.headSha');\n return Object.freeze({\n deliveryId: matches(event.deliveryId, DELIVERY, 'event.deliveryId'),\n provider: 'github',\n eventType: subscription,\n pullRequest: Object.freeze({ owner, repository, number: pr.number }),\n });\n}\n\n/** Cloud's answer is data from across a boundary; an unknown shape fails the run. */\nexport function turnReceipt(value: unknown): BabysitterTurnReceipt {\n const receipt = record(value, 'turn receipt', ['receiptId', 'status'], ['receiptId', 'status']);\n const receiptId = matches(receipt.receiptId, RECEIPT, 'turn receipt receiptId');\n if (receipt.status !== 'queued' && receipt.status !== 'duplicate') refuse('turn receipt status is unknown.');\n return Object.freeze({ receiptId, status: receipt.status });\n}\n" + } + ] +} diff --git a/web/data/flow-plugin-catalog.v1.json b/web/data/flow-plugin-catalog.v1.json index 8ed1a36..0a99aa8 100644 --- a/web/data/flow-plugin-catalog.v1.json +++ b/web/data/flow-plugin-catalog.v1.json @@ -3,7 +3,7 @@ "plugins": [ { "name": "babysitter", - "description": "Native Babysitter for Software Garden: eleven GitHub wake events request one authorized existing-session turn. Cloud rechecks the live babysit label, pull-request head, and session binding; the extension holds no GitHub write or merge authority. This entry is catalog-only until the Cloud capability adapter and Relay native delivery are both merged and deployed.", + "description": "Native Babysitter for Software Garden: eleven GitHub wake events request one authorized existing-session turn. Cloud rechecks the live babysit label, pull-request head, and session binding; the extension holds no GitHub write or merge authority. This entry is catalog-only until the Cloud adapter, targeted Relay/Flows executor, and Relay native delivery are all merged and deployed.", "source": { "owner": "AgentWorkforce", "repo": "flows", "path": "extensions/babysitter" }, "ref": "8b33ebab8347514f80d9da5a81206a087f641714", "digest": "bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee", @@ -12,6 +12,7 @@ "runtime": { "package": "@relayflows/sdk", "version": "2.0.31", "release": "v2.0.31" }, "activation": { "state": "blocked", + "liveProof": null, "dependencies": [ { "id": "cloud-babysitter-capability-adapter", @@ -19,6 +20,12 @@ "requiredState": "merged-and-deployed", "evidence": null }, + { + "id": "relay-hosted-flow-extension-execution", + "repository": "AgentWorkforce/relay", + "requiredState": "merged-and-deployed", + "evidence": null + }, { "id": "relay-native-existing-session-delivery", "repository": "AgentWorkforce/relay", diff --git a/web/data/recommended-flow-catalog.v1.json b/web/data/recommended-flow-catalog.v1.json index 752eec4..aea625c 100644 --- a/web/data/recommended-flow-catalog.v1.json +++ b/web/data/recommended-flow-catalog.v1.json @@ -1,9 +1,10 @@ { "schemaVersion": 1, - "catalogVersion": 3, + "catalogVersion": 4, "flows": [ { "id": "software-factory", + "kind": "flow", "version": 3, "name": "Software Garden", "summary": "Turn GitHub issues into reviewed, tested pull requests.", @@ -33,6 +34,7 @@ }, "activation": { "state": "blocked", + "liveProof": null, "dependencies": [ { "id": "cloud-babysitter-capability-adapter", @@ -40,6 +42,12 @@ "requiredState": "merged-and-deployed", "evidence": null }, + { + "id": "relay-hosted-flow-extension-execution", + "repository": "AgentWorkforce/relay", + "requiredState": "merged-and-deployed", + "evidence": null + }, { "id": "relay-native-existing-session-delivery", "repository": "AgentWorkforce/relay", @@ -62,6 +70,72 @@ "mediaType": "text/typescript", "sha256": "49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6" } + }, + { + "id": "babysitter", + "kind": "extension", + "baseFlowId": "software-factory", + "version": 1, + "name": "Babysitter", + "summary": "Keep a Software Garden session moving when GitHub activity needs another turn.", + "description": "A first-party Software Garden extension that turns eleven verified GitHub wake events into one authorized existing-session turn. It is discoverable now, but activation remains unavailable until Cloud hosting and Relay delivery carry exact deployment evidence and a live label-to-turn-to-receipt proof.", + "defaultLabel": "Babysitter", + "supportedRepositoryHosts": ["github"], + "defaultTrigger": { + "provider": "github", + "settings": {} + }, + "inputs": { + "required": [], + "defaults": { "agents": [] }, + "allowedAgents": [] + }, + "extension": { + "id": "babysitter", + "version": "0.2.0", + "runtime": { "package": "@relayflows/sdk", "version": "2.0.31", "release": "v2.0.31" }, + "artifact": { + "ref": "github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter", + "digest": "bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee", + "manifestSha256": "5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226" + }, + "activation": { + "state": "blocked", + "liveProof": null, + "dependencies": [ + { + "id": "cloud-babysitter-capability-adapter", + "repository": "AgentWorkforce/cloud", + "requiredState": "merged-and-deployed", + "evidence": null + }, + { + "id": "relay-hosted-flow-extension-execution", + "repository": "AgentWorkforce/relay", + "requiredState": "merged-and-deployed", + "evidence": null + }, + { + "id": "relay-native-existing-session-delivery", + "repository": "AgentWorkforce/relay", + "requiredState": "merged-and-deployed", + "evidence": null + } + ] + } + }, + "source": { + "kind": "github", + "owner": "AgentWorkforce", + "repo": "flows", + "path": "extensions/babysitter/babysitter.flow.ts", + "release": "v2.0.26", + "ref": "8b33ebab8347514f80d9da5a81206a087f641714", + "url": "https://github.com/AgentWorkforce/flows/blob/8b33ebab8347514f80d9da5a81206a087f641714/extensions/babysitter/babysitter.flow.ts", + "rawUrl": "https://raw.githubusercontent.com/AgentWorkforce/flows/8b33ebab8347514f80d9da5a81206a087f641714/extensions/babysitter/babysitter.flow.ts", + "mediaType": "text/typescript", + "sha256": "e1e8e9690334360612a6f6e749746e6a322eefea922f61342479c3bac18490b6" + } } ] } diff --git a/web/lib/agent-signup.ts b/web/lib/agent-signup.ts index ae0ccbd..f1c4202 100644 --- a/web/lib/agent-signup.ts +++ b/web/lib/agent-signup.ts @@ -370,6 +370,16 @@ Do not invent a repository or enable automation on an unrelated project. GET ${site}/api/v1/flows/catalog and select a matching entry from flows. GET ${site}/api/v1/flows/catalog/ for its full contract. Use the catalog's +kind before doing anything executable: only an entry with kind: flow (or a +legacy entry with kind omitted) is deployable through the direct-source API +below. An entry with kind: extension is discoverable metadata, not a standalone +listener. Do not download or deploy its source, even when it declares trigger +and input fields. Inspect baseFlowId and extension.activation instead. If the +extension is blocked, report the unmet dependencies and that it can only be +added to its base flow after a supported extension activation path is available; +never substitute a standalone flow deployment. + +For a deployable flow, use the catalog's supportedRepositoryHosts, defaultTrigger, inputs.required, inputs.defaults, and inputs.allowedAgents. Name a model per harness in inputs.models when the house default is wrong (for example {"claude": "claude-sonnet-4"}); omit it to fund @@ -459,7 +469,7 @@ workspace, verified source, repository, GitHub approver and a new UUID: } ~~~ -For another catalog entry use its id as workflow, allowed agents, and +For another deployable kind: flow catalog entry use its id as workflow, allowed agents, and defaultTrigger for sources, then apply the user's trigger settings. Scope a GitHub issue trigger with settings.repository set to the chosen owner/name; for GitLab use settings.project. Cloud does not derive this filter from the diff --git a/web/lib/flow-plugin-catalog.ts b/web/lib/flow-plugin-catalog.ts index a8886c1..f4ac2a1 100644 --- a/web/lib/flow-plugin-catalog.ts +++ b/web/lib/flow-plugin-catalog.ts @@ -8,6 +8,7 @@ export type FlowPluginTrustTier = (typeof FLOW_PLUGIN_TRUST_TIERS)[number]; export const FLOW_PLUGIN_REQUIRED_DEPENDENCIES: Readonly>>> = { babysitter: { 'cloud-babysitter-capability-adapter': 'AgentWorkforce/cloud', + 'relay-hosted-flow-extension-execution': 'AgentWorkforce/relay', 'relay-native-existing-session-delivery': 'AgentWorkforce/relay', }, }; @@ -27,8 +28,19 @@ export type FlowPluginActivationDependency = { evidence: FlowPluginDeploymentEvidence | null; }; +export type FlowPluginLiveProof = { + evidenceUrl: string; + evidenceSha256: string; + observedAt: string; + pullRequestUrl: string; + headSha: string; + label: 'babysit'; + receiptId: string; +}; + export type FlowPluginActivationGate = { state: 'blocked' | 'ready'; + liveProof: FlowPluginLiveProof | null; dependencies: FlowPluginActivationDependency[]; }; @@ -158,6 +170,39 @@ export function flowPluginDependencyHasDeploymentEvidence( } } +export function flowPluginHasLiveProof(proof: FlowPluginLiveProof | null): boolean { + if (!proof || typeof proof !== 'object' || Array.isArray(proof)) return false; + if (Object.keys(proof).sort().join(',') !== 'evidenceSha256,evidenceUrl,headSha,label,observedAt,pullRequestUrl,receiptId') return false; + if (proof.label !== 'babysit' || !FULL_SHA.test(proof.headSha) + || !/^[0-9a-f]{64}$/.test(proof.evidenceSha256) + || timestampMillis(proof.observedAt) === null + || !/^[A-Za-z0-9_.:-]{1,200}$/.test(proof.receiptId)) return false; + try { + const evidenceUrl = new URL(proof.evidenceUrl); + const pullRequestUrl = new URL(proof.pullRequestUrl); + return evidenceUrl.origin === 'https://raw.githubusercontent.com' + && /^\/AgentWorkforce\/cloud\/[0-9a-f]{40}\/.+\.json$/.test(evidenceUrl.pathname) + && pullRequestUrl.origin === 'https://github.com' + && /^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/.test(pullRequestUrl.pathname); + } catch { + return false; + } +} + +export function flowPluginLiveProofFollowsDeployments( + proof: FlowPluginLiveProof | null, + dependencies: FlowPluginActivationDependency[], + now = Date.now(), +): boolean { + if (!proof || !flowPluginHasLiveProof(proof)) return false; + const observedAt = timestampMillis(proof.observedAt)!; + return observedAt <= now && dependencies.every((dependency) => { + if (!dependency.evidence || typeof dependency.evidence.deployedAt !== 'string') return false; + const deployedAt = timestampMillis(dependency.evidence.deployedAt); + return deployedAt !== null && observedAt >= deployedAt; + }); +} + export function flowPluginIsActivatable(plugin: FlowPluginCatalogEntry): boolean { const required = FLOW_PLUGIN_REQUIRED_DEPENDENCIES[plugin.name]; if (!Object.hasOwn(FLOW_PLUGIN_REQUIRED_DEPENDENCIES, plugin.name)) return false; @@ -166,7 +211,8 @@ export function flowPluginIsActivatable(plugin: FlowPluginCatalogEntry): boolean && actual.length === Object.keys(required).length && Object.keys(required).every(id => actual.includes(id)) && plugin.activation.dependencies.every(dependency => required[dependency.id] === dependency.repository) - && plugin.activation.dependencies.every(flowPluginDependencyHasDeploymentEvidence); + && plugin.activation.dependencies.every(flowPluginDependencyHasDeploymentEvidence) + && flowPluginLiveProofFollowsDeployments(plugin.activation.liveProof, plugin.activation.dependencies); } function originFrom(appOrigin: string): string { diff --git a/web/lib/flow-plugin-implementation-prs.d.mts b/web/lib/flow-plugin-implementation-prs.d.mts index 369e0e0..812d593 100644 --- a/web/lib/flow-plugin-implementation-prs.d.mts +++ b/web/lib/flow-plugin-implementation-prs.d.mts @@ -1 +1 @@ -export const FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS: Readonly>; +export const FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS: Readonly>; diff --git a/web/lib/flow-plugin-implementation-prs.mjs b/web/lib/flow-plugin-implementation-prs.mjs index e8e1035..2ffa71a 100644 --- a/web/lib/flow-plugin-implementation-prs.mjs +++ b/web/lib/flow-plugin-implementation-prs.mjs @@ -4,5 +4,7 @@ */ export const FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS = Object.freeze({ 'cloud-babysitter-capability-adapter': 'https://github.com/AgentWorkforce/cloud/pull/3989', + // No production owner PR exists yet. Null deliberately makes deployment evidence inadmissible. + 'relay-hosted-flow-extension-execution': null, 'relay-native-existing-session-delivery': 'https://github.com/AgentWorkforce/relay/pull/1851', }); diff --git a/web/lib/recommended-flow-catalog.ts b/web/lib/recommended-flow-catalog.ts index 6ed55c4..4990a95 100644 --- a/web/lib/recommended-flow-catalog.ts +++ b/web/lib/recommended-flow-catalog.ts @@ -1,6 +1,17 @@ import catalog from '../data/recommended-flow-catalog.v1.json'; +import babysitterBundle from '../data/babysitter-extension.v1.json'; -export type RecommendedFlow = typeof catalog.flows[number]; +type CatalogFlow = typeof catalog.flows[number]; +type BabysitterBundle = typeof babysitterBundle; +type CatalogExtension = NonNullable; +type CatalogEmbeddedExtension = NonNullable[number]; + +export type RecommendedFlow = CatalogFlow & { + extension?: CatalogExtension & { bundle: BabysitterBundle }; + extensions?: Array; +}; /** The checked-in JSON is the one catalog consumed by the API, Cloud and CLI. */ export function getRecommendedFlowCatalog() { @@ -8,5 +19,25 @@ export function getRecommendedFlowCatalog() { } export function getRecommendedFlow(id: string): RecommendedFlow | null { - return catalog.flows.find(flow => flow.id === id) ?? null; + const flow = catalog.flows.find(entry => entry.id === id); + if (!flow) return null; + + if (flow.id === 'babysitter' && 'extension' in flow) { + return { + ...flow, + extension: { ...flow.extension, bundle: babysitterBundle }, + } as RecommendedFlow; + } + + const extensions = 'extensions' in flow ? flow.extensions : undefined; + if (flow.id === 'software-factory' && extensions) { + return { + ...flow, + extensions: extensions.map(extension => ( + extension.id === 'babysitter' ? { ...extension, bundle: babysitterBundle } : extension + )), + } as RecommendedFlow; + } + + return flow as RecommendedFlow; } diff --git a/web/lib/test/agent-signup.test.ts b/web/lib/test/agent-signup.test.ts index 6b04226..1c7eef1 100644 --- a/web/lib/test/agent-signup.test.ts +++ b/web/lib/test/agent-signup.test.ts @@ -97,6 +97,9 @@ describe('agent signup instructions', () => { expect(deploy).not.toHaveProperty('flowId'); expect(deploy).not.toHaveProperty('repositories'); expect(content).toContain('one deployment per'); + expect(content).toContain('only an entry with kind: flow'); + expect(content).toContain('An entry with kind: extension is discoverable metadata, not a standalone'); + expect(content).toContain('never substitute a standalone flow deployment'); expect(content).toContain('Ask for the approver\'s'); expect(content).toContain('GitHub username in plain language'); expect(content).toContain('Normalize the'); diff --git a/web/lib/test/deployment-receipts.test.ts b/web/lib/test/deployment-receipts.test.ts index 6e85eed..75a4529 100644 --- a/web/lib/test/deployment-receipts.test.ts +++ b/web/lib/test/deployment-receipts.test.ts @@ -48,6 +48,20 @@ describe('authoritative dependency receipts', () => { expect(request).not.toHaveBeenCalled(); } }); + it('rejects the unassigned hosted executor before making a request', async () => { + const request = requester([]); + await expect(verifyDeploymentReceipt({ + ...dependency, + id: 'relay-hosted-flow-extension-execution', + repository: 'AgentWorkforce/relay', + evidence: { + ...evidence, + pullRequestUrl: 'https://github.com/AgentWorkforce/relay/pull/1900', + deploymentUrl: 'https://api.github.com/repos/AgentWorkforce/relay/deployments/7', + }, + }, request)).rejects.toThrow('declared capability implementation'); + expect(request).not.toHaveBeenCalled(); + }); it('rejects a nonexistent PR, private receipt, rate limit or failed request', async () => { for (const status of [404, 403, 429, 500]) { const request = vi.fn().mockResolvedValue(new Response('', { status })); diff --git a/web/lib/test/flow-plugin-catalog.test.ts b/web/lib/test/flow-plugin-catalog.test.ts index 36a65b1..7adead8 100644 --- a/web/lib/test/flow-plugin-catalog.test.ts +++ b/web/lib/test/flow-plugin-catalog.test.ts @@ -7,6 +7,8 @@ import { flowPluginBadgeMarkdown, flowPluginDependencyHasDeploymentEvidence, flowPluginGithubRef, + flowPluginHasLiveProof, + flowPluginLiveProofFollowsDeployments, flowPluginInstallHref, flowPluginInstallPath, flowPluginIsActivatable, @@ -44,6 +46,7 @@ describe('flow plugin catalog', () => { runtime: { package: '@relayflows/sdk', version: '2.0.31', release: 'v2.0.31' }, activation: { state: 'blocked', + liveProof: null, dependencies: [ { id: 'cloud-babysitter-capability-adapter', @@ -51,6 +54,12 @@ describe('flow plugin catalog', () => { requiredState: 'merged-and-deployed', evidence: null, }, + { + id: 'relay-hosted-flow-extension-execution', + repository: 'AgentWorkforce/relay', + requiredState: 'merged-and-deployed', + evidence: null, + }, { id: 'relay-native-existing-session-delivery', repository: 'AgentWorkforce/relay', @@ -87,18 +96,31 @@ describe('flow plugin catalog', () => { ...babysitter, activation: { state: 'ready' as const, + liveProof: { + evidenceUrl: `https://raw.githubusercontent.com/AgentWorkforce/cloud/${'c'.repeat(40)}/evidence/babysitter-live-proof.json`, + evidenceSha256: 'd'.repeat(64), + observedAt: '2026-09-24T12:10:00Z', + pullRequestUrl: 'https://github.com/AgentWorkforce/cloud/pull/4000', + headSha: 'e'.repeat(40), + label: 'babysit' as const, + receiptId: 'receipt:babysitter:e2e', + }, dependencies: babysitter.activation.dependencies.map((dependency, index) => ({ ...dependency, - evidence: evidence(dependency.repository, [3989, 1851][index]!), + evidence: evidence(dependency.repository, [3989, 1900, 1851][index]!), })), }, }; - expect(ready.activation.dependencies.every(flowPluginDependencyHasDeploymentEvidence)).toBe(true); - expect(flowPluginIsActivatable(ready)).toBe(true); - const install = new URL(flowPluginInstallHref(ready)!, 'https://agentrelay.com'); - expect(install.pathname).toBe('/cloud/flows/deploy'); - expect(install.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL); - expect(install.searchParams.getAll('plugin')).toEqual([flowPluginSourceUrl(ready)]); + expect(flowPluginHasLiveProof(ready.activation.liveProof)).toBe(true); + expect(flowPluginLiveProofFollowsDeployments( + ready.activation.liveProof, + ready.activation.dependencies, + )).toBe(true); + expect(ready.activation.dependencies.map(flowPluginDependencyHasDeploymentEvidence)).toEqual([ + true, false, true, + ]); + expect(flowPluginIsActivatable(ready)).toBe(false); + expect(flowPluginInstallHref(ready)).toBeNull(); const dependency = ready.activation.dependencies[0]!; const invalidEvidence: unknown[] = [null, [], {}, { ...dependency.evidence, extra: 'field' }]; @@ -183,6 +205,28 @@ describe('flow plugin catalog', () => { ...ready, activation: { ...ready.activation, state: 'blocked' }, })).toBe(false); + expect(flowPluginIsActivatable({ + ...ready, + activation: { ...ready.activation, liveProof: null }, + })).toBe(false); + expect(flowPluginHasLiveProof({ + ...ready.activation.liveProof, + evidenceUrl: 'https://example.com/proof.json', + })).toBe(false); + expect(flowPluginIsActivatable({ + ...ready, + activation: { + ...ready.activation, + liveProof: { ...ready.activation.liveProof, observedAt: '2026-09-24T12:04:59Z' }, + }, + })).toBe(false); + expect(flowPluginIsActivatable({ + ...ready, + activation: { + ...ready.activation, + liveProof: { ...ready.activation.liveProof, observedAt: '2099-09-24T12:10:00Z' }, + }, + })).toBe(false); }); it('builds a GitHub tree URL at the pinned sha, not a branch', () => { diff --git a/web/lib/test/recommended-extension-gates.test.ts b/web/lib/test/recommended-extension-gates.test.ts index 5343bfe..1c8330e 100644 --- a/web/lib/test/recommended-extension-gates.test.ts +++ b/web/lib/test/recommended-extension-gates.test.ts @@ -1,17 +1,30 @@ import { describe, expect, it } from 'vitest'; import pluginsJson from '../../data/flow-plugin-catalog.v1.json'; import recommendedJson from '../../data/recommended-flow-catalog.v1.json'; -import { validateRecommendedExtensions } from '../../scripts/verify-catalog-gates.mjs'; +import { createHash } from 'node:crypto'; +import { + integratedLiveProofTimingIsValid, + validateRecommendedExtensions, + verifyIntegratedLiveProof, +} from '../../scripts/verify-catalog-gates.mjs'; function fixture() { const plugins = structuredClone(pluginsJson); const catalog = structuredClone(recommendedJson); + const garden = catalog.flows.find(flow => flow.id === 'software-factory') as any; // A second compatible base demonstrates traversal beyond Software Garden. plugins.plugins[0].base.push('second-flow'); - catalog.flows.push({ ...structuredClone(catalog.flows[0]), id: 'second-flow' }); + catalog.flows.push({ ...structuredClone(garden), id: 'second-flow' }); return { plugins, catalog }; } +function baseFlows(catalog: ReturnType['catalog']) { + return [ + catalog.flows.find(flow => flow.id === 'software-factory')!, + catalog.flows.find(flow => flow.id === 'second-flow')!, + ] as any[]; +} + describe('every recommended extension gate', () => { it('accepts matching blocked contracts across all compatible flows', () => { const { plugins, catalog } = fixture(); @@ -20,8 +33,9 @@ describe('every recommended extension gate', () => { it('rejects an unsupported second extension on either flow', () => { for (const index of [0, 1]) { const { plugins, catalog } = fixture(); - catalog.flows[index].extensions.push({ - ...structuredClone(catalog.flows[index].extensions[0]), id: 'unsupported', + const flow = baseFlows(catalog)[index]; + flow.extensions.push({ + ...structuredClone(flow.extensions[0]), id: 'unsupported', activation: { state: 'ready', dependencies: [] }, }); expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('no supported plugin contract'); @@ -29,22 +43,23 @@ describe('every recommended extension gate', () => { }); it('validates evidence for a supported extension on the second flow', () => { const { plugins, catalog } = fixture(); - catalog.flows[1].extensions[0].activation.state = 'ready'; + baseFlows(catalog)[1].extensions[0].activation.state = 'ready'; expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('may be ready only'); }); it('rejects every artifact or runtime mismatch on the second flow', () => { for (const field of ['ref', 'digest', 'manifestSha256'] as const) { const { plugins, catalog } = fixture(); - catalog.flows[1].extensions[0].artifact[field] = 'unverified'; + baseFlows(catalog)[1].extensions[0].artifact[field] = 'unverified'; expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('artifact coordinates'); } const { plugins, catalog } = fixture(); - catalog.flows[1].extensions[0].runtime.version = '99.0.0'; + baseFlows(catalog)[1].extensions[0].runtime.version = '99.0.0'; expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('runtime provenance'); }); it('rejects duplicate extensions, duplicate plugin contracts and incompatible bases', () => { const duplicate = fixture(); - duplicate.catalog.flows[1].extensions.push(duplicate.catalog.flows[1].extensions[0]); + const duplicateSecond = baseFlows(duplicate.catalog)[1]; + duplicateSecond.extensions.push(duplicateSecond.extensions[0]); expect(() => validateRecommendedExtensions(duplicate.catalog, duplicate.plugins)).toThrow('is repeated'); const ambiguous = fixture(); ambiguous.plugins.plugins.push(ambiguous.plugins.plugins[0]); @@ -56,8 +71,120 @@ describe('every recommended extension gate', () => { it('rejects malformed extension lists instead of skipping them', () => { for (const extensions of [null, {}, 'babysitter']) { const { plugins, catalog } = fixture(); - const malformed = { ...catalog, flows: [catalog.flows[0], { ...catalog.flows[1], extensions }] }; + const [garden, second] = baseFlows(catalog); + const babysitter = catalog.flows.find(flow => flow.id === 'babysitter') as any; + const malformed = { ...catalog, flows: [garden, babysitter, { ...second, extensions }] }; expect(() => validateRecommendedExtensions(malformed, plugins)).toThrow('extensions must be an array'); } }); + it('validates the first-class Babysitter card against the same plugin gate', () => { + const { plugins, catalog } = fixture(); + const babysitter = catalog.flows.find(flow => flow.id === 'babysitter') as any; + babysitter.extension.activation.state = 'ready'; + expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('may be ready only'); + }); + it('enforces the paired Cloud list-consumer envelope', () => { + const missingLabel = fixture(); + const babysitter = missingLabel.catalog.flows.find(flow => flow.id === 'babysitter') as any; + delete babysitter.defaultLabel; + expect(() => validateRecommendedExtensions(missingLabel.catalog, missingLabel.plugins)) + .toThrow('Cloud recommended catalog consumer contract'); + + const missingAgentArray = fixture(); + const secondBabysitter = missingAgentArray.catalog.flows.find(flow => flow.id === 'babysitter') as any; + secondBabysitter.inputs.defaults = {}; + expect(() => validateRecommendedExtensions(missingAgentArray.catalog, missingAgentArray.plugins)) + .toThrow('Cloud recommended catalog consumer contract'); + + const emptyDeployableFlow = fixture(); + baseFlows(emptyDeployableFlow.catalog)[0].inputs.defaults.agents = []; + expect(() => validateRecommendedExtensions(emptyDeployableFlow.catalog, emptyDeployableFlow.plugins)) + .toThrow('deployable flow agent sets must not be empty'); + }); + it('keeps ready blocked while the hosted executor has no accepted implementation PR', () => { + const { plugins, catalog } = fixture(); + const activation = structuredClone(plugins.plugins[0].activation) as any; + activation.state = 'ready'; + activation.dependencies.forEach((dependency: any, index: number) => { + const pull = [3989, 1900, 1851][index]!; + dependency.evidence = { + pullRequestUrl: `https://github.com/${dependency.repository}/pull/${pull}`, + mergedCommit: `${index + 1}`.repeat(40), + mergedAt: '2026-09-24T12:00:00Z', + deploymentUrl: `https://api.github.com/repos/${dependency.repository}/deployments/${pull}`, + deployedAt: '2026-09-24T12:05:00Z', + }; + }); + plugins.plugins[0].activation = activation; + for (const flow of catalog.flows as any[]) { + if (flow.extension?.id === 'babysitter') flow.extension.activation = structuredClone(activation); + for (const extension of flow.extensions ?? []) { + if (extension.id === 'babysitter') extension.activation = structuredClone(activation); + } + } + expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('merge and deployment evidence'); + }); + it('requires live proof to follow deployments and not be future-dated', () => { + const proof = { + evidenceUrl: `https://raw.githubusercontent.com/AgentWorkforce/cloud/${'a'.repeat(40)}/evidence/babysitter-live-proof.json`, + evidenceSha256: 'b'.repeat(64), + observedAt: '2026-09-24T12:04:59Z', + pullRequestUrl: 'https://github.com/AgentWorkforce/cloud/pull/4000', + headSha: 'c'.repeat(40), + label: 'babysit' as const, + receiptId: 'receipt:babysitter:e2e', + }; + const dependencies = [{ + id: 'dependency', + repository: 'AgentWorkforce/example', + requiredState: 'merged-and-deployed' as const, + evidence: { + pullRequestUrl: 'https://github.com/AgentWorkforce/example/pull/1', + mergedCommit: 'd'.repeat(40), + mergedAt: '2026-09-24T12:00:00Z', + deploymentUrl: 'https://api.github.com/repos/AgentWorkforce/example/deployments/1', + deployedAt: '2026-09-24T12:05:00Z', + }, + }]; + expect(integratedLiveProofTimingIsValid(proof, dependencies, Date.parse('2026-09-24T12:10:00Z'))).toBe(false); + expect(integratedLiveProofTimingIsValid( + { ...proof, observedAt: '2026-09-24T12:06:00Z' }, + dependencies, + Date.parse('2026-09-24T12:10:00Z'), + )).toBe(true); + expect(integratedLiveProofTimingIsValid( + { ...proof, observedAt: '2026-09-24T12:11:00Z' }, + dependencies, + Date.parse('2026-09-24T12:10:00Z'), + )).toBe(false); + }); + it('verifies the immutable live proof bytes and declared payload', async () => { + const document = { + headSha: 'a'.repeat(40), + label: 'babysit' as const, + observedAt: '2026-09-24T12:10:00Z', + pullRequestUrl: 'https://github.com/AgentWorkforce/cloud/pull/4000', + receiptId: 'receipt:babysitter:e2e', + }; + const bytes = Buffer.from(JSON.stringify(document)); + const proof = { + evidenceUrl: `https://raw.githubusercontent.com/AgentWorkforce/cloud/${'b'.repeat(40)}/evidence/babysitter-live-proof.json`, + evidenceSha256: createHash('sha256').update(bytes).digest('hex'), + ...document, + }; + const response = () => Promise.resolve(new Response(bytes, { + status: 200, + headers: { 'content-length': String(bytes.length) }, + })); + await expect(verifyIntegratedLiveProof(proof, response)).resolves.toBeUndefined(); + await expect(verifyIntegratedLiveProof( + { ...proof, evidenceSha256: 'c'.repeat(64) }, + response, + )).rejects.toThrow('digest does not match'); + const drifted = Buffer.from(JSON.stringify({ ...document, receiptId: 'different' })); + await expect(verifyIntegratedLiveProof( + { ...proof, evidenceSha256: createHash('sha256').update(drifted).digest('hex') }, + () => Promise.resolve(new Response(drifted, { status: 200 })), + )).rejects.toThrow('payload does not match'); + }); }); diff --git a/web/lib/test/recommended-flow-catalog.test.ts b/web/lib/test/recommended-flow-catalog.test.ts index 23e69eb..1fdb03b 100644 --- a/web/lib/test/recommended-flow-catalog.test.ts +++ b/web/lib/test/recommended-flow-catalog.test.ts @@ -9,11 +9,12 @@ import { GET as getCatalogItem } from '../../app/api/v1/flows/catalog/[flowId]/r describe('recommended flow catalog', () => { it('publishes Software Garden metadata under its canonical flow id', () => { const catalog = getRecommendedFlowCatalog(); - expect(catalog).toEqual({ + expect(catalog).toMatchObject({ schemaVersion: 1, - catalogVersion: 3, + catalogVersion: 4, flows: [{ id: 'software-factory', + kind: 'flow', version: 3, name: 'Software Garden', summary: expect.any(String), @@ -37,6 +38,7 @@ describe('recommended flow catalog', () => { }, activation: { state: 'blocked', + liveProof: null, dependencies: [ { id: 'cloud-babysitter-capability-adapter', @@ -44,6 +46,12 @@ describe('recommended flow catalog', () => { requiredState: 'merged-and-deployed', evidence: null, }, + { + id: 'relay-hosted-flow-extension-execution', + repository: 'AgentWorkforce/relay', + requiredState: 'merged-and-deployed', + evidence: null, + }, { id: 'relay-native-existing-session-delivery', repository: 'AgentWorkforce/relay', @@ -65,20 +73,58 @@ describe('recommended flow catalog', () => { mediaType: 'text/typescript', sha256: '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6', }, + }, { + id: 'babysitter', + kind: 'extension', + baseFlowId: 'software-factory', + name: 'Babysitter', + defaultLabel: 'Babysitter', + inputs: { + required: [], + defaults: { agents: [] }, + allowedAgents: [], + }, + extension: { + id: 'babysitter', + version: '0.2.0', + artifact: { + ref: 'github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter', + digest: 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee', + manifestSha256: '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226', + }, + activation: { state: 'blocked' }, + }, + source: { + path: 'extensions/babysitter/babysitter.flow.ts', + ref: '8b33ebab8347514f80d9da5a81206a087f641714', + sha256: 'e1e8e9690334360612a6f6e749746e6a322eefea922f61342479c3bac18490b6', + }, }], }); expect(JSON.parse(JSON.stringify(catalog))).toEqual(catalog); - expect(catalog.flows[0]?.extensions[0]?.activation.state).toBe('blocked'); + expect(catalog.flows[0]?.extensions?.[0]?.activation.state).toBe('blocked'); }); - it('keeps display branding separate from the canonical activation id', () => { + it('publishes Babysitter as a first-class, blocked Software Garden extension', () => { expect(getRecommendedFlow('software-factory')?.name).toBe('Software Garden'); expect(getRecommendedFlow('software-garden')).toBeNull(); - expect(getRecommendedFlow('babysitter')).toBeNull(); + const babysitter = getRecommendedFlow('babysitter'); + expect(babysitter).toMatchObject({ + kind: 'extension', + baseFlowId: 'software-factory', + extension: { + activation: { state: 'blocked' }, + bundle: { + name: 'babysitter', + digest: 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee', + manifest: { permissions: { writes: ['cloud:babysitter-turn'] } }, + }, + }, + }); }); it('references an immutable released source instead of carrying authored source', () => { - const flow = getRecommendedFlow('software-factory')!; + const flow = getRecommendedFlow('babysitter')!; expect(flow.source.ref).toMatch(/^[0-9a-f]{40}$/); expect(flow.source.sha256).toMatch(/^[0-9a-f]{64}$/); expect(flow.source.url).toContain(`/blob/${flow.source.ref}/${flow.source.path}`); @@ -94,7 +140,16 @@ describe('recommended flow catalog HTTP surface', () => { const list = getCatalog(); expect(list.status).toBe(200); expect(list.headers.get('access-control-allow-origin')).toBe('*'); - await expect(list.json()).resolves.toMatchObject({ schemaVersion: 1, flows: [{ id: 'software-factory' }] }); + await expect(list.json()).resolves.toMatchObject({ + schemaVersion: 1, + catalogVersion: 4, + flows: [{ id: 'software-factory' }, { + id: 'babysitter', + kind: 'extension', + defaultLabel: 'Babysitter', + inputs: { defaults: { agents: [] }, allowedAgents: [] }, + }], + }); const detail = await getCatalogItem(new Request('https://agentrelay.com/api/v1/flows/catalog/software-factory'), { params: Promise.resolve({ flowId: 'software-factory' }), @@ -106,10 +161,24 @@ describe('recommended flow catalog HTTP surface', () => { source: { ref: '8b33ebab8347514f80d9da5a81206a087f641714' }, extensions: [{ id: 'babysitter', activation: { state: 'blocked' } }], }); + + const babysitter = await getCatalogItem(new Request('https://agentrelay.com/api/v1/flows/catalog/babysitter'), { + params: Promise.resolve({ flowId: 'babysitter' }), + }); + expect(babysitter.status).toBe(200); + await expect(babysitter.json()).resolves.toMatchObject({ + id: 'babysitter', + kind: 'extension', + baseFlowId: 'software-factory', + extension: { + activation: { state: 'blocked' }, + bundle: { name: 'babysitter', files: expect.any(Array) }, + }, + }); }); it('does not treat display names or future concepts as catalog ids', async () => { - for (const flowId of ['software-garden', 'babysitter', 'unknown']) { + for (const flowId of ['software-garden', 'unknown']) { const response = await getCatalogItem(new Request(`https://agentrelay.com/api/v1/flows/catalog/${flowId}`), { params: Promise.resolve({ flowId }), }); diff --git a/web/scripts/verify-catalog-gates.d.mts b/web/scripts/verify-catalog-gates.d.mts index e2d5156..c722ee1 100644 --- a/web/scripts/verify-catalog-gates.d.mts +++ b/web/scripts/verify-catalog-gates.d.mts @@ -1,3 +1,13 @@ -import type { FlowPluginActivationDependency } from '../lib/flow-plugin-catalog'; +import type { FlowPluginActivationDependency, FlowPluginLiveProof } from '../lib/flow-plugin-catalog'; export function deploymentEvidenceIsValid(dependency: FlowPluginActivationDependency): boolean; +export function integratedLiveProofIsValid(proof: FlowPluginLiveProof | null): boolean; +export function integratedLiveProofTimingIsValid( + proof: FlowPluginLiveProof | null, + dependencies: FlowPluginActivationDependency[], + now?: number, +): boolean; +export function verifyIntegratedLiveProof( + proof: FlowPluginLiveProof, + request?: (input: string | URL | Request, init?: RequestInit) => Promise, +): Promise; export function validateRecommendedExtensions(recommendedCatalog: unknown, pluginCatalog: unknown): void; diff --git a/web/scripts/verify-catalog-gates.mjs b/web/scripts/verify-catalog-gates.mjs index b6db178..7bde5f0 100644 --- a/web/scripts/verify-catalog-gates.mjs +++ b/web/scripts/verify-catalog-gates.mjs @@ -1,18 +1,23 @@ import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from '../lib/flow-plugin-implementation-prs.mjs'; +import { assertPluginArtifact } from './verify-plugin-artifacts.mjs'; +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { verifyDeploymentReceipt } from './verify-deployment-receipts.mjs'; const pluginCatalogUrl = new URL('../data/flow-plugin-catalog.v1.json', import.meta.url); const recommendedCatalogUrl = new URL('../data/recommended-flow-catalog.v1.json', import.meta.url); +const babysitterBundleUrl = new URL('../data/babysitter-extension.v1.json', import.meta.url); -const [pluginCatalog, recommendedCatalog] = await Promise.all([ +const [pluginCatalog, recommendedCatalog, babysitterBundle] = await Promise.all([ readFile(pluginCatalogUrl, 'utf8').then(JSON.parse), readFile(recommendedCatalogUrl, 'utf8').then(JSON.parse), + readFile(babysitterBundleUrl, 'utf8').then(JSON.parse), ]); const REQUIRED_PLUGIN_DEPENDENCIES = new Map([ ['babysitter', new Map([ ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'], + ['relay-hosted-flow-extension-execution', 'AgentWorkforce/relay'], ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'], ])], ]); @@ -31,6 +36,79 @@ function timestampMillis(value) { return new Date(milliseconds).toISOString() === normalized ? milliseconds : null; } +export function integratedLiveProofIsValid(proof) { + if (!proof || typeof proof !== 'object' || Array.isArray(proof)) return false; + if (Object.keys(proof).sort().join(',') !== 'evidenceSha256,evidenceUrl,headSha,label,observedAt,pullRequestUrl,receiptId') return false; + if (!Object.values(proof).every(value => typeof value === 'string') + || proof.label !== 'babysit' + || !SHA.test(proof.headSha) + || !/^[0-9a-f]{64}$/.test(proof.evidenceSha256) + || timestampMillis(proof.observedAt) === null + || !/^[A-Za-z0-9_.:-]{1,200}$/.test(proof.receiptId)) return false; + try { + const evidenceUrl = new URL(proof.evidenceUrl); + const pullRequestUrl = new URL(proof.pullRequestUrl); + return evidenceUrl.origin === 'https://raw.githubusercontent.com' + && /^\/AgentWorkforce\/cloud\/[0-9a-f]{40}\/.+\.json$/.test(evidenceUrl.pathname) + && pullRequestUrl.origin === 'https://github.com' + && /^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/.test(pullRequestUrl.pathname); + } catch { + return false; + } +} + +export function integratedLiveProofTimingIsValid(proof, dependencies, now = Date.now()) { + if (!integratedLiveProofIsValid(proof) || !Array.isArray(dependencies)) return false; + const observedAt = timestampMillis(proof.observedAt); + return observedAt <= now && dependencies.every((dependency) => { + const deployedAt = typeof dependency?.evidence?.deployedAt === 'string' + ? timestampMillis(dependency.evidence.deployedAt) + : null; + return deployedAt !== null && observedAt >= deployedAt; + }); +} + +const MAX_LIVE_PROOF_BYTES = 64 * 1024; + +/** Read-only verification of the immutable integrated-run proof. */ +export async function verifyIntegratedLiveProof(proof, request = fetch) { + if (!integratedLiveProofIsValid(proof)) { + throw new Error('live proof does not satisfy the integrated-run contract'); + } + const response = await request(proof.evidenceUrl, { + redirect: 'error', + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`live proof unavailable: HTTP ${response.status}`); + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_LIVE_PROOF_BYTES) { + throw new Error(`live proof exceeds ${MAX_LIVE_PROOF_BYTES} bytes`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > MAX_LIVE_PROOF_BYTES) { + throw new Error(`live proof exceeds ${MAX_LIVE_PROOF_BYTES} bytes`); + } + if (createHash('sha256').update(bytes).digest('hex') !== proof.evidenceSha256) { + throw new Error('live proof digest does not match the catalog'); + } + let document; + try { + document = JSON.parse(bytes.toString('utf8')); + } catch { + throw new Error('live proof is not valid JSON'); + } + const expected = { + headSha: proof.headSha, + label: proof.label, + observedAt: proof.observedAt, + pullRequestUrl: proof.pullRequestUrl, + receiptId: proof.receiptId, + }; + if (JSON.stringify(document) !== JSON.stringify(expected)) { + throw new Error('live proof payload does not match the catalog'); + } +} + export function deploymentEvidenceIsValid(dependency) { const evidence = dependency.evidence; if (evidence === null) return false; @@ -61,7 +139,7 @@ export function deploymentEvidenceIsValid(dependency) { function validateActivationGate(gate, label, requiredDependencies) { if (!gate || typeof gate !== 'object' || Array.isArray(gate)) fail(`${label} has no activation gate`); - if (Object.keys(gate).sort().join(',') !== 'dependencies,state') fail(`${label} activation gate has unknown fields`); + if (Object.keys(gate).sort().join(',') !== 'dependencies,liveProof,state') fail(`${label} activation gate has unknown fields`); if (gate.state !== 'blocked' && gate.state !== 'ready') fail(`${label} activation state must be blocked or ready`); if (!Array.isArray(gate.dependencies) || gate.dependencies.length !== requiredDependencies.size) { fail(`${label} must declare its complete runtime dependency set`); @@ -85,19 +163,52 @@ function validateActivationGate(gate, label, requiredDependencies) { if (gate.state === 'ready' && !allDependenciesProven) { fail(`${label} may be ready only when every dependency carries merge and deployment evidence`); } + if (gate.state === 'ready' && !integratedLiveProofIsValid(gate.liveProof)) { + fail(`${label} may be ready only with immutable live label-to-turn-to-receipt proof`); + } + if (gate.state === 'ready') { + const observedAt = timestampMillis(gate.liveProof.observedAt); + if (observedAt > Date.now()) { + fail(`${label} live proof observation may not be in the future`); + } + if (!integratedLiveProofTimingIsValid(gate.liveProof, gate.dependencies)) { + fail(`${label} live proof must postdate every dependency deployment`); + } + } } if (pluginCatalog.version !== 3 || !Array.isArray(pluginCatalog.plugins)) { fail('flow plugin catalog must be version 3'); } -if (recommendedCatalog.schemaVersion !== 1 || recommendedCatalog.catalogVersion !== 3 || !Array.isArray(recommendedCatalog.flows)) { - fail('recommended flow catalog must be schemaVersion 1 and catalogVersion 3'); +if (recommendedCatalog.schemaVersion !== 1 || recommendedCatalog.catalogVersion !== 4 || !Array.isArray(recommendedCatalog.flows)) { + fail('recommended flow catalog must be schemaVersion 1 and catalogVersion 4'); } const plugin = pluginCatalog.plugins.find(entry => entry.name === 'babysitter'); const garden = recommendedCatalog.flows.find(flow => flow.id === 'software-factory'); const extension = garden?.extensions?.find(entry => entry.id === 'babysitter'); -if (!plugin || !garden || !extension) fail('Babysitter must exist in both catalogs'); +const babysitter = recommendedCatalog.flows.find(flow => flow.id === 'babysitter'); +if (!plugin || !garden || !extension || !babysitter) fail('Babysitter must exist as a plugin, Garden extension, and recommended entry'); + +function validateExtensionContract(extension, label, baseFlowId, plugins) { + if (!extension || typeof extension !== 'object' || Array.isArray(extension)) fail(`${label} is invalid`); + const plugin = plugins.get(extension.id); + if (!plugin) fail(`${label} has no supported plugin contract`); + if (!plugin.base.includes(baseFlowId)) fail(`${label} is incompatible with this base flow`); + validateActivationGate(extension.activation, label, REQUIRED_PLUGIN_DEPENDENCIES.get(extension.id)); + const ref = `github:${plugin.source.owner}/${plugin.source.repo}@${plugin.ref}#${plugin.source.path}`; + if (!extension.artifact || extension.artifact.ref !== ref + || extension.artifact.digest !== plugin.digest + || extension.artifact.manifestSha256 !== plugin.manifestSha256) { + fail(`${label} artifact coordinates drifted from its plugin contract`); + } + if (JSON.stringify(extension.runtime) !== JSON.stringify(plugin.runtime)) { + fail(`${label} runtime provenance drifted from its plugin contract`); + } + if (JSON.stringify(extension.activation) !== JSON.stringify(plugin.activation)) { + fail(`${label} activation gate drifted from its plugin contract`); + } +} /** Every published extension must use a supported, validated plugin contract. */ export function validateRecommendedExtensions(recommendedCatalog, pluginCatalog) { @@ -113,6 +224,22 @@ export function validateRecommendedExtensions(recommendedCatalog, pluginCatalog) for (const flow of recommendedCatalog.flows) { if (flowIds.has(flow.id)) fail(`recommended catalog repeats flow ${flow.id}`); flowIds.add(flow.id); + if (typeof flow.defaultLabel !== 'string' || !flow.defaultLabel + || !flow.inputs || typeof flow.inputs !== 'object' || Array.isArray(flow.inputs) + || !flow.inputs.defaults || typeof flow.inputs.defaults !== 'object' || Array.isArray(flow.inputs.defaults) + || !Array.isArray(flow.inputs.defaults.agents) || !Array.isArray(flow.inputs.allowedAgents)) { + fail(`${flow.id} does not satisfy the Cloud recommended catalog consumer contract`); + } + if (flow.kind !== 'extension' + && (flow.inputs.defaults.agents.length === 0 || flow.inputs.allowedAgents.length === 0)) { + fail(`${flow.id} deployable flow agent sets must not be empty`); + } + if (flow.kind === 'extension') { + if (typeof flow.baseFlowId !== 'string' || !flow.baseFlowId || flow.extension?.id !== flow.id) { + fail(`${flow.id} must name its base flow and matching extension contract`); + } + validateExtensionContract(flow.extension, `recommended entry ${flow.id}`, flow.baseFlowId, plugins); + } if (flow.extensions === undefined) continue; if (!Array.isArray(flow.extensions)) fail(`${flow.id} extensions must be an array`); const extensionIds = new Set(); @@ -121,28 +248,41 @@ export function validateRecommendedExtensions(recommendedCatalog, pluginCatalog) if (!extension || typeof extension !== 'object' || Array.isArray(extension)) fail(`${label} is invalid`); if (extensionIds.has(extension.id)) fail(`${label} is repeated`); extensionIds.add(extension.id); - const plugin = plugins.get(extension.id); - if (!plugin) fail(`${label} has no supported plugin contract`); - if (!plugin.base.includes(flow.id)) fail(`${label} is incompatible with this base flow`); - validateActivationGate(extension.activation, label, REQUIRED_PLUGIN_DEPENDENCIES.get(extension.id)); - const ref = `github:${plugin.source.owner}/${plugin.source.repo}@${plugin.ref}#${plugin.source.path}`; - if (!extension.artifact || extension.artifact.ref !== ref - || extension.artifact.digest !== plugin.digest - || extension.artifact.manifestSha256 !== plugin.manifestSha256) { - fail(`${label} artifact coordinates drifted from its plugin contract`); - } - if (JSON.stringify(extension.runtime) !== JSON.stringify(plugin.runtime)) { - fail(`${label} runtime provenance drifted from its plugin contract`); - } - if (JSON.stringify(extension.activation) !== JSON.stringify(plugin.activation)) { - fail(`${label} activation gate drifted from its plugin contract`); - } + validateExtensionContract(extension, label, flow.id, plugins); } } } validateRecommendedExtensions(recommendedCatalog, pluginCatalog); +if (babysitter.kind !== 'extension' || babysitter.baseFlowId !== 'software-factory') { + fail('Babysitter recommended entry must identify its Software Garden relationship'); +} +if (babysitterBundle.name !== plugin.name || babysitterBundle.version !== extension.version + || babysitterBundle.ref !== extension.artifact.ref + || babysitterBundle.digest !== plugin.digest + || babysitterBundle.manifestSha256 !== plugin.manifestSha256) { + fail('Babysitter bundle identity drifted from its catalog contract'); +} +if (!Array.isArray(babysitterBundle.files) || babysitterBundle.files.length === 0) { + fail('Babysitter bundle has no files'); +} +const bundleFiles = babysitterBundle.files.map(file => { + if (!file || typeof file !== 'object' || file.encoding !== 'utf8' || typeof file.content !== 'string') { + fail('Babysitter bundle contains an invalid file'); + } + const data = Buffer.from(file.content, 'utf8'); + if (data.length !== file.bytes || createHash('sha256').update(data).digest('hex') !== file.sha256) { + fail(`Babysitter bundle file ${file.path ?? ''} failed integrity verification`); + } + return { path: file.path, data }; +}); +assertPluginArtifact(plugin, bundleFiles); +const manifestFile = babysitterBundle.files.find(file => file.path === 'flows-plugin.json'); +if (!manifestFile || JSON.stringify(JSON.parse(manifestFile.content)) !== JSON.stringify(babysitterBundle.manifest)) { + fail('Babysitter bundle manifest metadata drifted from flows-plugin.json'); +} + if (plugin.runtime.package !== '@relayflows/sdk' || plugin.runtime.version !== '2.0.31' || plugin.runtime.release !== 'v2.0.31') { @@ -153,6 +293,7 @@ if (plugin.runtime.package !== '@relayflows/sdk' for (const entry of pluginCatalog.plugins) { if (entry.activation.state !== 'ready') continue; for (const dependency of entry.activation.dependencies) await verifyDeploymentReceipt(dependency); + await verifyIntegratedLiveProof(entry.activation.liveProof); } console.log('catalog gates: Babysitter metadata is pinned and activation is fail-closed'); diff --git a/web/scripts/verify-deployment-receipts.mjs b/web/scripts/verify-deployment-receipts.mjs index 96638ea..16c3a11 100644 --- a/web/scripts/verify-deployment-receipts.mjs +++ b/web/scripts/verify-deployment-receipts.mjs @@ -2,6 +2,7 @@ import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from '../lib/flow-plugin-imp const REPOSITORIES = new Map([ ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'], + ['relay-hosted-flow-extension-execution', 'AgentWorkforce/relay'], ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'], ]); diff --git a/web/scripts/verify-recommended-flow-catalog.mjs b/web/scripts/verify-recommended-flow-catalog.mjs index 68c8442..23a05bf 100644 --- a/web/scripts/verify-recommended-flow-catalog.mjs +++ b/web/scripts/verify-recommended-flow-catalog.mjs @@ -10,8 +10,8 @@ const catalogUrl = new URL('../data/recommended-flow-catalog.v1.json', import.me const catalog = JSON.parse(await readFile(catalogUrl, 'utf8')); const MAX_SOURCE_BYTES = 1024 * 1024; -if (catalog.schemaVersion !== 1 || catalog.catalogVersion !== 3 || !Array.isArray(catalog.flows)) { - throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 3, with a flows array'); +if (catalog.schemaVersion !== 1 || catalog.catalogVersion !== 4 || !Array.isArray(catalog.flows)) { + throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 4, with a flows array'); } for (const flow of catalog.flows) {