diff --git a/package.json b/package.json index f0ccfad..7722a8a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "dev": "npm --workspace web run dev", "build": "npm --workspace web run build", "test": "npm --workspaces run test", - "verify:recommended-flows": "node web/scripts/verify-recommended-flow-catalog.mjs", + "verify:catalog-gates": "node web/scripts/verify-catalog-gates.mjs", + "verify:recommended-flows": "npm run verify:catalog-gates && node web/scripts/verify-recommended-flow-catalog.mjs", "preview": "npm --workspace web run preview", "dev:teams": "CLOUD_DEV_ORIGIN=http://127.0.0.1:3101 npm --workspace web run dev -- --hostname 127.0.0.1 --port 3100" } diff --git a/web/app/flows/plugins/PluginsGallery.tsx b/web/app/flows/plugins/PluginsGallery.tsx index 265e262..8846f69 100644 --- a/web/app/flows/plugins/PluginsGallery.tsx +++ b/web/app/flows/plugins/PluginsGallery.tsx @@ -60,8 +60,8 @@ export function PluginsGallery() {

A curated catalog of schema-2 flow extensions. Each card pins a public GitHub - directory and an install badge that opens Cloud with the base flow and the plugin - source. + directory. Install controls appear only after every declared runtime dependency has + merge and deployment evidence.

@@ -106,7 +106,7 @@ export function PluginsGallery() { const pluginUrl = flowPluginSourceUrl(plugin); const tier = FLOW_PLUGIN_TRUST_TIER_COPY[plugin.tier]; const badgeMarkdown = - flowUrl && + installHref && flowUrl && flowPluginBadgeMarkdown({ flowUrl, plugins: [pluginUrl], @@ -130,7 +130,14 @@ export function PluginsGallery() { ))} sha {plugin.ref.slice(0, 12)} sha256:{plugin.digest.slice(0, 12)} + runtime {plugin.runtime.version} + {plugin.activation.state === 'blocked' ? ( +

+ Catalog only: activation is blocked. Required merge and deployment evidence: + {' '}{plugin.activation.dependencies.map(dependency => dependency.id).join(', ')}. +

+ ) : null} {pluginHasUnroutableTriggers(plugin) ? (

Fail-closed: GitHub pull_request.ready_for_review,{' '} diff --git a/web/app/flows/plugins/page.tsx b/web/app/flows/plugins/page.tsx index c58d9f0..94c4951 100644 --- a/web/app/flows/plugins/page.tsx +++ b/web/app/flows/plugins/page.tsx @@ -6,9 +6,9 @@ import { SiteNav } from '../../../components/SiteNav'; import { absoluteUrl, SITE_NAME } from '../../../lib/site'; import { PluginsGallery } from './PluginsGallery'; -const title = 'Flow plugins — Install onto a base flow'; +const title = 'Flow plugins — Catalog and release status'; const description = - 'A curated catalog of schema-2 flow extensions. Install Babysitter onto Software Garden with a pinned sha, digest, and trust tier.'; + 'A curated catalog of schema-2 flow extensions with pinned artifacts, runtime provenance, trust tiers, and fail-closed activation gates.'; export const metadata: Metadata = { title, diff --git a/web/content/docs/relayflows/plugins.mdx b/web/content/docs/relayflows/plugins.mdx index 80815e8..f371ed9 100644 --- a/web/content/docs/relayflows/plugins.mdx +++ b/web/content/docs/relayflows/plugins.mdx @@ -9,7 +9,7 @@ A **flow plugin** is a directory in a public GitHub repository whose `flows-plug { "schema": 2, "kind": "flow-extension" } ``` -The entry default-exports `flow()` and declares handlers, hooks, triggers, permissions, compat, and the same mandatory `preflight`. Browse the vendored catalog on the [plugin gallery](/flows/plugins). Each card's **Install plugin** badge opens Cloud with the base flow and the plugin source: +The entry default-exports `flow()` and declares handlers, hooks, triggers, permissions, compat, and the same mandatory `preflight`. Browse the vendored catalog on the [plugin gallery](/flows/plugins). An available card's **Install plugin** badge opens Cloud with the base flow and the plugin source: ```text /cloud/flows/deploy?flow=&plugin= @@ -18,7 +18,7 @@ The entry default-exports `flow()` and declares handlers, hooks, triggers, permi `plugin` may repeat. The badge helper appends each one so a second plugin does not overwrite the first. - See the plugin gallery — Babysitter on Software Garden, with the install badge. + See the plugin gallery — including Babysitter's pinned artifact and release gate. ## `flows-plugin.json` schema 2 @@ -67,7 +67,7 @@ Rules that matter at review time: - Manifest triggers are validated against the surface registry `providerEventTypes`. An event the registry cannot route is `plugin_event_unroutable`. - Relayflows 2.0.26 carries all eleven Babysitter event identities, but the authored executor still refuses a matching handler before either body starts because schema-2 permissions are not isolated there. Cloud may enable Babysitter only through the separately authorized existing-session lineage path; an arbitrary labelled pull request is not execution authority. + Relayflows SDK 2.0.31 carries the isolated hosted runtime for the reviewed Babysitter artifact, but no Cloud capability adapter or Relay native existing-session delivery release exists yet. The catalog therefore remains metadata-only and exposes no install control. Cloud may enable Babysitter only after both dependencies are merged and deployed; an arbitrary labelled pull request is not execution authority. ## `flows add` @@ -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 v2 is **first-party**: its `v2.0.26` commit is reachable from `AgentWorkforce/flows` main. The label does not widen runtime authority. +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. `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. @@ -126,12 +126,26 @@ Babysitter in catalog v2 is **first-party**: its `v2.0.26` commit is reachable f ## Install badge -`flowPluginBadgeMarkdown()` renders the README form. `plugin` is appended so repeats survive: - -```markdown -[![Install plugin](https://agentrelay.com/deploy-flow_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2F8b33ebab8347514f80d9da5a81206a087f641714%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F8b33ebab8347514f80d9da5a81206a087f641714%2Fextensions%2Fbabysitter) -``` - -The first plugin in the vendored catalog is Babysitter. Its base flow URL is Software Garden (`software-factory`); its plugin URL is the tree at the pinned sha. The [plugin gallery](/flows/plugins) shows the live badge. - -The catalog itself is versioned JSON, `{version:2, plugins:[{name, description, source:{owner,repo,path}, ref, digest, manifestSha256, compat, tier, base}]}`. Any public repo with a `flows-plugin.json` is installable by URL; the catalog is a curated index, not a gate. +`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. + +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. + +Ready catalog entries must pass live, read-only GitHub receipt verification in CI. +The code-owned implementation contract binds `cloud-babysitter-capability-adapter` +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 +requires a reviewed code change to this contract. +`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`). +That deployment must identify the same commit in the non-transient `production` +environment, and its latest status must be successful with `created_at` matching +`deployedAt`. Arbitrary HTTPS links, build runs, private/unavailable receipts and +unsupported deployment systems cannot make an entry ready. Blocked entries do not +require network receipts and may retain evidence without enabling installation. +The client checks the published catalog's shape; CI verifies its provenance, and +Cloud remains responsible for server-side activation authorization. diff --git a/web/content/docs/relayflows/recommended.mdx b/web/content/docs/relayflows/recommended.mdx index c855865..6c45e21 100644 --- a/web/content/docs/relayflows/recommended.mdx +++ b/web/content/docs/relayflows/recommended.mdx @@ -9,9 +9,9 @@ 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 2 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 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. -Software Garden currently supports GitHub repositories. Deploy one listener per repository using the direct-source API below. The catalog deliberately does not define Babysitter behavior. Babysitter is a [flow plugin](/docs/relayflows/plugins) installed onto Software Garden, not a recommended flow of its own. +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. ## Catalog API @@ -22,7 +22,7 @@ GET https://agentrelay.com/api/v1/flows/catalog GET https://agentrelay.com/api/v1/flows/catalog/software-factory ``` -The list response starts with `schemaVersion: 1` and `catalogVersion: 2`. 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. +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 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/flow-plugin-catalog.v1.json b/web/data/flow-plugin-catalog.v1.json index e163ad7..8ed1a36 100644 --- a/web/data/flow-plugin-catalog.v1.json +++ b/web/data/flow-plugin-catalog.v1.json @@ -1,14 +1,32 @@ { - "version": 2, + "version": 3, "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. Handler execution remains fail-closed until the Cloud capability adapter and native delivery path ship.", + "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.", "source": { "owner": "AgentWorkforce", "repo": "flows", "path": "extensions/babysitter" }, "ref": "8b33ebab8347514f80d9da5a81206a087f641714", "digest": "bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee", "manifestSha256": "5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226", "compat": { "surface": "^2.0.26", "sdk": "^2.0.26", "base": ["software-factory"] }, + "runtime": { "package": "@relayflows/sdk", "version": "2.0.31", "release": "v2.0.31" }, + "activation": { + "state": "blocked", + "dependencies": [ + { + "id": "cloud-babysitter-capability-adapter", + "repository": "AgentWorkforce/cloud", + "requiredState": "merged-and-deployed", + "evidence": null + }, + { + "id": "relay-native-existing-session-delivery", + "repository": "AgentWorkforce/relay", + "requiredState": "merged-and-deployed", + "evidence": null + } + ] + }, "tier": "first-party", "base": ["software-factory"] } diff --git a/web/data/recommended-flow-catalog.v1.json b/web/data/recommended-flow-catalog.v1.json index 08fb7d1..752eec4 100644 --- a/web/data/recommended-flow-catalog.v1.json +++ b/web/data/recommended-flow-catalog.v1.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "catalogVersion": 2, + "catalogVersion": 3, "flows": [ { "id": "software-factory", - "version": 2, + "version": 3, "name": "Software Garden", "summary": "Turn GitHub issues into reviewed, tested pull requests.", "description": "Implement a ticket, run the repository checks outside the agent, put the change through adversarial review, and open a pull request for a human decision.", @@ -21,6 +21,35 @@ }, "allowedAgents": ["claude"] }, + "extensions": [ + { + "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", + "dependencies": [ + { + "id": "cloud-babysitter-capability-adapter", + "repository": "AgentWorkforce/cloud", + "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", diff --git a/web/lib/flow-plugin-catalog.ts b/web/lib/flow-plugin-catalog.ts index e1e4654..a8886c1 100644 --- a/web/lib/flow-plugin-catalog.ts +++ b/web/lib/flow-plugin-catalog.ts @@ -1,9 +1,37 @@ import catalogJson from '../data/flow-plugin-catalog.v1.json'; +import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from './flow-plugin-implementation-prs.mjs'; import { SITE_URL } from './site'; export const FLOW_PLUGIN_TRUST_TIERS = ['first-party', 'verified', 'community'] as const; export type FlowPluginTrustTier = (typeof FLOW_PLUGIN_TRUST_TIERS)[number]; +export const FLOW_PLUGIN_REQUIRED_DEPENDENCIES: Readonly>>> = { + babysitter: { + 'cloud-babysitter-capability-adapter': 'AgentWorkforce/cloud', + 'relay-native-existing-session-delivery': 'AgentWorkforce/relay', + }, +}; + +export type FlowPluginDeploymentEvidence = { + pullRequestUrl: string; + mergedCommit: string; + mergedAt: string; + deploymentUrl: string; + deployedAt: string; +}; + +export type FlowPluginActivationDependency = { + id: string; + repository: string; + requiredState: 'merged-and-deployed'; + evidence: FlowPluginDeploymentEvidence | null; +}; + +export type FlowPluginActivationGate = { + state: 'blocked' | 'ready'; + dependencies: FlowPluginActivationDependency[]; +}; + export type FlowPluginCatalogEntry = { name: string; description: string; @@ -12,12 +40,14 @@ export type FlowPluginCatalogEntry = { digest: string; manifestSha256: string; compat: { surface: string; sdk: string; base: string[] }; + runtime: { package: '@relayflows/sdk'; version: string; release: string }; + activation: FlowPluginActivationGate; tier: FlowPluginTrustTier; base: string[]; }; export type FlowPluginCatalog = { - version: 2; + version: 3; plugins: FlowPluginCatalogEntry[]; }; @@ -89,6 +119,56 @@ export function pluginHasUnroutableTriggers(plugin: FlowPluginCatalogEntry): boo return plugin.description.includes('plugin_event_unroutable'); } +const FULL_SHA = /^[0-9a-f]{40}$/; +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; + +function timestampMillis(value: string): number | null { + if (!ISO_TIMESTAMP.test(value)) return null; + const milliseconds = Date.parse(value); + if (!Number.isFinite(milliseconds)) return null; + const normalized = value.includes('.') ? value : value.replace(/Z$/, '.000Z'); + return new Date(milliseconds).toISOString() === normalized ? milliseconds : null; +} + +export function flowPluginDependencyHasDeploymentEvidence( + dependency: FlowPluginActivationDependency, +): boolean { + const evidence = dependency.evidence; + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence) + || dependency.requiredState !== 'merged-and-deployed') return false; + if (Object.keys(evidence).sort().join(',') !== 'deployedAt,deploymentUrl,mergedAt,mergedCommit,pullRequestUrl' + || !Object.values(evidence).every(value => typeof value === 'string')) return false; + if (!Object.hasOwn(FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS, dependency.id) + || evidence.pullRequestUrl !== FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS[dependency.id]) return false; + try { + const pullRequestUrl = new URL(evidence.pullRequestUrl); + const deploymentUrl = new URL(evidence.deploymentUrl); + const mergedAt = timestampMillis(evidence.mergedAt); + const deployedAt = timestampMillis(evidence.deployedAt); + return pullRequestUrl.origin === 'https://github.com' + && new RegExp(`^/${dependency.repository}/pull/[1-9][0-9]*$`).test(pullRequestUrl.pathname) + && FULL_SHA.test(evidence.mergedCommit) + && mergedAt !== null + && deploymentUrl.href === `https://api.github.com/repos/${dependency.repository}/deployments/${deploymentUrl.pathname.split('/').pop()}` + && /^[1-9][0-9]*$/.test(deploymentUrl.pathname.split('/').pop() ?? '') + && deployedAt !== null + && deployedAt >= mergedAt; + } catch { + return false; + } +} + +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; + const actual = plugin.activation.dependencies.map(dependency => dependency.id); + return plugin.activation.state === 'ready' + && 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); +} + function originFrom(appOrigin: string): string { return appOrigin.replace(/\/+$/, ''); } @@ -111,6 +191,7 @@ export function flowPluginInstallPath(input: { flowUrl: string; plugins: string[ } export function flowPluginInstallHref(plugin: FlowPluginCatalogEntry): string | null { + if (!flowPluginIsActivatable(plugin)) return null; const flowUrl = pluginInstallFlowUrl(plugin); if (!flowUrl) return null; return flowPluginInstallPath({ flowUrl, plugins: [flowPluginSourceUrl(plugin)] }); diff --git a/web/lib/flow-plugin-implementation-prs.d.mts b/web/lib/flow-plugin-implementation-prs.d.mts new file mode 100644 index 0000000..369e0e0 --- /dev/null +++ b/web/lib/flow-plugin-implementation-prs.d.mts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..e8e1035 --- /dev/null +++ b/web/lib/flow-plugin-implementation-prs.mjs @@ -0,0 +1,8 @@ +/** + * Code-owned capability implementations, independent of catalog-supplied receipts. + * A replacement implementation requires review of this contract, not a catalog edit. + */ +export const FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS = Object.freeze({ + 'cloud-babysitter-capability-adapter': 'https://github.com/AgentWorkforce/cloud/pull/3989', + 'relay-native-existing-session-delivery': 'https://github.com/AgentWorkforce/relay/pull/1851', +}); diff --git a/web/lib/test/deployment-receipts.test.ts b/web/lib/test/deployment-receipts.test.ts new file mode 100644 index 0000000..6e85eed --- /dev/null +++ b/web/lib/test/deployment-receipts.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { verifyDeploymentReceipt } from '../../scripts/verify-deployment-receipts.mjs'; + +const repository = 'AgentWorkforce/cloud'; +const api = `https://api.github.com/repos/${repository}`; +const evidence = { + pullRequestUrl: `https://github.com/${repository}/pull/3989`, + mergedCommit: 'a'.repeat(40), mergedAt: '2026-09-24T12:00:00Z', + deploymentUrl: `${api}/deployments/7`, deployedAt: '2026-09-24T12:05:00Z', +}; +const dependency = { id: 'cloud-babysitter-capability-adapter', repository, + requiredState: 'merged-and-deployed' as const, evidence }; +const pr = { merged: true, base: { ref: 'main', repo: { full_name: repository } }, + html_url: evidence.pullRequestUrl, merge_commit_sha: evidence.mergedCommit, merged_at: evidence.mergedAt }; +const deployment = { url: evidence.deploymentUrl, sha: evidence.mergedCommit, + repository_url: api, environment: 'production', production_environment: true, + transient_environment: false, created_at: '2026-09-24T12:01:00Z' }; +const success = { state: 'success', environment: 'production', deployment_url: evidence.deploymentUrl, + repository_url: api, created_at: evidence.deployedAt }; +function requester(bodies: unknown[]) { + return vi.fn().mockImplementation(async () => Response.json(bodies.shift())); +} + +describe('authoritative dependency receipts', () => { + it('checks the merged PR, exact production SHA and latest successful status', async () => { + const request = requester([pr, deployment, [success]]); + await expect(verifyDeploymentReceipt(dependency, request)).resolves.toBeUndefined(); + expect(request.mock.calls.map(call => call[0])).toEqual([ + `${api}/pulls/3989`, `${api}/deployments/7`, `${api}/deployments/7/statuses?per_page=1`, + ]); + }); + it('rejects an unrelated merged/deployed PR in either dependency repository', async () => { + for (const [id, repo] of [ + ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'], + ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'], + ]) { + const pullRequestUrl = `https://github.com/${repo}/pull/42`; + const repositoryUrl = `https://api.github.com/repos/${repo}`; + const deploymentUrl = `${repositoryUrl}/deployments/7`; + const request = requester([ + { ...pr, html_url: pullRequestUrl, base: { ref: 'main', repo: { full_name: repo } } }, + { ...deployment, url: deploymentUrl, repository_url: repositoryUrl }, + [{ ...success, deployment_url: deploymentUrl, repository_url: repositoryUrl }], + ]); + await expect(verifyDeploymentReceipt({ ...dependency, id, repository: repo, + evidence: { ...evidence, pullRequestUrl, deploymentUrl }, + }, 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 })); + await expect(verifyDeploymentReceipt(dependency, request)).rejects.toThrow('unavailable'); + } + await expect(verifyDeploymentReceipt(dependency, vi.fn() + .mockRejectedValue(new Error('network unavailable')))).rejects.toThrow(); + }); + it('refuses arbitrary URLs and mismatched repositories before making requests', async () => { + const request = requester([]); + for (const deploymentUrl of ['https://example.com/not-a-deployment', `${api}/deployments/7?fake=1`, + 'https://api.github.com/repos/other/repo/deployments/7']) { + await expect(verifyDeploymentReceipt({ ...dependency, evidence: { ...evidence, deploymentUrl } }, request)) + .rejects.toThrow(); + } + await expect(verifyDeploymentReceipt({ ...dependency, repository: 'other/repo' }, request)).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); + }); + it('rejects unmerged, wrong-commit, wrong-date and wrong-base PR receipts', async () => { + for (const patch of [{ merged: false }, { merge_commit_sha: 'b'.repeat(40) }, + { merged_at: '2026-09-24T11:00:00Z' }, { base: { ref: 'other', repo: { full_name: repository } } }]) { + await expect(verifyDeploymentReceipt(dependency, requester([{ ...pr, ...patch }]))) + .rejects.toThrow('PR receipt'); + } + }); + it('rejects another SHA, staging, transient, and pre-merge deployments', async () => { + for (const patch of [{ sha: 'b'.repeat(40) }, { environment: 'staging' }, + { production_environment: false }, { transient_environment: true }, + { created_at: '2026-09-24T11:00:00Z' }]) { + await expect(verifyDeploymentReceipt(dependency, requester([pr, { ...deployment, ...patch }]))) + .rejects.toThrow('deployment receipt'); + } + }); + it('rejects absent, failed, inactive, wrong-date or wrong-deployment statuses', async () => { + for (const statuses of [[], [{ ...success, state: 'failure' }, success], + [{ ...success, state: 'inactive' }], [{ ...success, created_at: '2026-09-24T12:04:00Z' }], + [{ ...success, deployment_url: `${api}/deployments/8` }]]) { + await expect(verifyDeploymentReceipt(dependency, requester([pr, deployment, statuses]))) + .rejects.toThrow('latest deployment status'); + } + }); +}); diff --git a/web/lib/test/flow-plugin-catalog.test.ts b/web/lib/test/flow-plugin-catalog.test.ts index 68a52b0..36a65b1 100644 --- a/web/lib/test/flow-plugin-catalog.test.ts +++ b/web/lib/test/flow-plugin-catalog.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; +import { deploymentEvidenceIsValid } from '../../scripts/verify-catalog-gates.mjs'; import { FLOW_PLUGIN_TRUST_TIERS, SOFTWARE_FACTORY_FLOW_URL, flowPluginBadgeMarkdown, + flowPluginDependencyHasDeploymentEvidence, flowPluginGithubRef, flowPluginInstallHref, flowPluginInstallPath, + flowPluginIsActivatable, flowPluginSourceUrl, getFlowPlugin, getFlowPluginCatalog, @@ -21,8 +24,8 @@ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; describe('flow plugin catalog', () => { const catalog = getFlowPluginCatalog(); - it('is version 2 with unique kebab-case plugin names', () => { - expect(catalog.version).toBe(2); + it('is version 3 with unique kebab-case plugin names', () => { + expect(catalog.version).toBe(3); expect(Array.isArray(catalog.plugins)).toBe(true); expect(catalog.plugins.length).toBeGreaterThan(0); const names = catalog.plugins.map((plugin) => plugin.name); @@ -38,6 +41,24 @@ describe('flow plugin catalog', () => { digest: 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee', manifestSha256: '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226', compat: { surface: '^2.0.26', sdk: '^2.0.26', base: ['software-factory'] }, + runtime: { package: '@relayflows/sdk', version: '2.0.31', release: 'v2.0.31' }, + activation: { + state: 'blocked', + dependencies: [ + { + id: 'cloud-babysitter-capability-adapter', + repository: 'AgentWorkforce/cloud', + requiredState: 'merged-and-deployed', + evidence: null, + }, + { + id: 'relay-native-existing-session-delivery', + repository: 'AgentWorkforce/relay', + requiredState: 'merged-and-deployed', + evidence: null, + }, + ], + }, tier: 'first-party', base: ['software-factory'], }); @@ -45,10 +66,123 @@ describe('flow plugin catalog', () => { expect(babysitter!.digest).toMatch(HEX64); expect(babysitter!.manifestSha256).toMatch(HEX64); expect(babysitter!.description).not.toContain('plugin_event_unroutable'); - expect(babysitter!.description).toContain('fail-closed'); + expect(babysitter!.description).toContain('catalog-only'); expect(babysitter!.description).toContain('no GitHub write or merge authority'); expect(FLOW_PLUGIN_TRUST_TIERS.includes(babysitter!.tier)).toBe(true); expect(pluginHasUnroutableTriggers(babysitter!)).toBe(false); + expect(flowPluginIsActivatable(babysitter!)).toBe(false); + expect(flowPluginInstallHref(babysitter!)).toBeNull(); + }); + + it('requires merge and deployment evidence for every dependency before activation', () => { + const babysitter = getFlowPlugin('babysitter')!; + const evidence = (repository: string, pull: number) => ({ + pullRequestUrl: `https://github.com/${repository}/pull/${pull}`, + mergedCommit: 'a'.repeat(40), + mergedAt: '2026-09-24T12:00:00Z', + deploymentUrl: `https://api.github.com/repos/${repository}/deployments/${pull}`, + deployedAt: '2026-09-24T12:05:00Z', + }); + const ready = { + ...babysitter, + activation: { + state: 'ready' as const, + dependencies: babysitter.activation.dependencies.map((dependency, index) => ({ + ...dependency, + evidence: evidence(dependency.repository, [3989, 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)]); + + const dependency = ready.activation.dependencies[0]!; + const invalidEvidence: unknown[] = [null, [], {}, { ...dependency.evidence, extra: 'field' }]; + for (const [key, value] of Object.entries(dependency.evidence)) { + for (const malformed of [[value], null, 123, {}, true]) { + invalidEvidence.push({ ...dependency.evidence, [key]: malformed }); + } + const missing = { ...dependency.evidence }; + delete missing[key as keyof typeof missing]; + invalidEvidence.push(missing); + } + for (const evidence of invalidEvidence) { + const invalid = { ...dependency, evidence } as typeof dependency; + expect(flowPluginDependencyHasDeploymentEvidence(invalid)).toBe(false); + expect(deploymentEvidenceIsValid(invalid)).toBe(false); + expect(flowPluginInstallHref({ ...ready, activation: { + ...ready.activation, dependencies: [invalid, ready.activation.dependencies[1]!], + } })).toBeNull(); + } + expect(deploymentEvidenceIsValid(dependency)).toBe(true); + expect(flowPluginDependencyHasDeploymentEvidence({ ...dependency, evidence: null })).toBe(false); + + for (const dependency of ready.activation.dependencies) { + const unrelated = { ...dependency, evidence: { ...dependency.evidence, + pullRequestUrl: `https://github.com/${dependency.repository}/pull/42`, + } }; + expect(flowPluginDependencyHasDeploymentEvidence(unrelated)).toBe(false); + expect(deploymentEvidenceIsValid(unrelated)).toBe(false); + expect(flowPluginInstallHref({ ...ready, activation: { ...ready.activation, + dependencies: ready.activation.dependencies.map(value => value.id === unrelated.id ? unrelated : value), + } })).toBeNull(); + } + + // Evidence is internally consistent but belongs to the wrong repository. + const wrongRepository = { ...dependency, repository: 'AgentWorkforce/other', + evidence: evidence('AgentWorkforce/other', 1) }; + expect(flowPluginDependencyHasDeploymentEvidence(wrongRepository)).toBe(false); + expect(flowPluginInstallHref({ ...ready, activation: { + ...ready.activation, dependencies: [wrongRepository, ready.activation.dependencies[1]!], + } })).toBeNull(); + + const missingDeployment = { + ...ready.activation.dependencies[0]!, + evidence: { ...ready.activation.dependencies[0]!.evidence!, deploymentUrl: '' }, + }; + expect(flowPluginDependencyHasDeploymentEvidence(missingDeployment)).toBe(false); + expect(flowPluginIsActivatable({ + ...ready, + activation: { ...ready.activation, dependencies: [missingDeployment, ready.activation.dependencies[1]!] }, + })).toBe(false); + + expect(flowPluginDependencyHasDeploymentEvidence({ + ...ready.activation.dependencies[0]!, + evidence: { + ...ready.activation.dependencies[0]!.evidence!, + pullRequestUrl: 'https://github.com/AgentWorkforce/cloud/pull/not-a-number', + }, + })).toBe(false); + + expect(flowPluginIsActivatable({ + ...ready, + activation: { ...ready.activation, dependencies: [ready.activation.dependencies[0]!] }, + })).toBe(false); + + expect(flowPluginDependencyHasDeploymentEvidence({ + ...ready.activation.dependencies[0]!, + evidence: { + ...ready.activation.dependencies[0]!.evidence!, + deployedAt: '2026-09-24T11:59:59Z', + }, + })).toBe(false); + + expect(flowPluginDependencyHasDeploymentEvidence({ + ...ready.activation.dependencies[0]!, + evidence: { + ...ready.activation.dependencies[0]!.evidence!, + mergedAt: '2026-02-30T12:00:00Z', + }, + })).toBe(false); + + expect(flowPluginIsActivatable({ + ...ready, + activation: { ...ready.activation, state: 'blocked' }, + })).toBe(false); }); it('builds a GitHub tree URL at the pinned sha, not a branch', () => { @@ -107,14 +241,10 @@ describe('flowPluginBadgeMarkdown', () => { ]); }); - it('wires the babysitter gallery card to Software Garden plus the pinned plugin tree', () => { + it('does not expose the Babysitter install route while its dependencies are blocked', () => { const babysitter = getFlowPlugin('babysitter')!; const href = flowPluginInstallHref(babysitter); - expect(href).toBeTruthy(); - const url = new URL(href!, 'https://agentrelay.com'); - expect(url.pathname).toBe('/cloud/flows/deploy'); - expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL); - expect(url.searchParams.getAll('plugin')).toEqual([flowPluginSourceUrl(babysitter)]); + expect(href).toBeNull(); }); }); diff --git a/web/lib/test/plugin-artifacts.test.ts b/web/lib/test/plugin-artifacts.test.ts new file mode 100644 index 0000000..f052bed --- /dev/null +++ b/web/lib/test/plugin-artifacts.test.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { assertPluginArtifact } from '../../scripts/verify-plugin-artifacts.mjs'; + +const hash = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex'); +const files = [ + { path: 'flows-plugin.json', data: Buffer.from('{"schemaVersion":2}') }, + { path: 'handler.ts', data: Buffer.from('export const handler = true;') }, +]; +const plugin = { + name: 'fixture', + digest: hash(JSON.stringify(files.map(file => ({ + bytes: file.data.length, path: file.path, sha256: hash(file.data), + })))), + manifestSha256: hash(files[0].data), +}; + +describe('immutable plugin artifact verification', () => { + it('accepts matching bytes independent of enumeration order', () => { + expect(() => assertPluginArtifact(plugin, [...files].reverse())).not.toThrow(); + }); + it('rejects absent manifests, changed content, missing files and unexpected files', () => { + for (const invalid of [[], files.slice(1), files.slice(0, 1), + [...files, { path: 'extra.ts', data: Buffer.from('extra') }], + [files[0], { ...files[1], data: Buffer.from('tampered') }], + ]) expect(() => assertPluginArtifact(plugin, invalid)).toThrow(); + }); + it('checks the manifest hash independently of the payload digest', () => { + expect(() => assertPluginArtifact({ ...plugin, manifestSha256: '0'.repeat(64) }, files)) + .toThrow('manifest digest mismatch'); + }); +}); diff --git a/web/lib/test/recommended-extension-gates.test.ts b/web/lib/test/recommended-extension-gates.test.ts new file mode 100644 index 0000000..5343bfe --- /dev/null +++ b/web/lib/test/recommended-extension-gates.test.ts @@ -0,0 +1,63 @@ +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'; + +function fixture() { + const plugins = structuredClone(pluginsJson); + const catalog = structuredClone(recommendedJson); + // 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' }); + return { plugins, catalog }; +} + +describe('every recommended extension gate', () => { + it('accepts matching blocked contracts across all compatible flows', () => { + const { plugins, catalog } = fixture(); + expect(() => validateRecommendedExtensions(catalog, plugins)).not.toThrow(); + }); + 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', + activation: { state: 'ready', dependencies: [] }, + }); + expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('no supported plugin contract'); + } + }); + it('validates evidence for a supported extension on the second flow', () => { + const { plugins, catalog } = fixture(); + catalog.flows[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'; + expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('artifact coordinates'); + } + const { plugins, catalog } = fixture(); + catalog.flows[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]); + expect(() => validateRecommendedExtensions(duplicate.catalog, duplicate.plugins)).toThrow('is repeated'); + const ambiguous = fixture(); + ambiguous.plugins.plugins.push(ambiguous.plugins.plugins[0]); + expect(() => validateRecommendedExtensions(ambiguous.catalog, ambiguous.plugins)).toThrow('repeats'); + const incompatible = fixture(); + incompatible.plugins.plugins[0].base = ['software-factory']; + expect(() => validateRecommendedExtensions(incompatible.catalog, incompatible.plugins)).toThrow('incompatible'); + }); + 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 }] }; + expect(() => validateRecommendedExtensions(malformed, plugins)).toThrow('extensions must be an array'); + } + }); +}); diff --git a/web/lib/test/recommended-flow-catalog.test.ts b/web/lib/test/recommended-flow-catalog.test.ts index 6aadea3..23e69eb 100644 --- a/web/lib/test/recommended-flow-catalog.test.ts +++ b/web/lib/test/recommended-flow-catalog.test.ts @@ -11,10 +11,10 @@ describe('recommended flow catalog', () => { const catalog = getRecommendedFlowCatalog(); expect(catalog).toEqual({ schemaVersion: 1, - catalogVersion: 2, + catalogVersion: 3, flows: [{ id: 'software-factory', - version: 2, + version: 3, name: 'Software Garden', summary: expect.any(String), description: expect.any(String), @@ -26,6 +26,33 @@ describe('recommended flow catalog', () => { defaults: { agents: ['claude'] }, allowedAgents: ['claude'], }, + extensions: [{ + 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', + dependencies: [ + { + id: 'cloud-babysitter-capability-adapter', + repository: 'AgentWorkforce/cloud', + 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', @@ -41,6 +68,7 @@ describe('recommended flow catalog', () => { }], }); expect(JSON.parse(JSON.stringify(catalog))).toEqual(catalog); + expect(catalog.flows[0]?.extensions[0]?.activation.state).toBe('blocked'); }); it('keeps display branding separate from the canonical activation id', () => { @@ -76,6 +104,7 @@ describe('recommended flow catalog HTTP surface', () => { id: 'software-factory', name: 'Software Garden', source: { ref: '8b33ebab8347514f80d9da5a81206a087f641714' }, + extensions: [{ id: 'babysitter', activation: { state: 'blocked' } }], }); }); diff --git a/web/scripts/verify-catalog-gates.d.mts b/web/scripts/verify-catalog-gates.d.mts new file mode 100644 index 0000000..e2d5156 --- /dev/null +++ b/web/scripts/verify-catalog-gates.d.mts @@ -0,0 +1,3 @@ +import type { FlowPluginActivationDependency } from '../lib/flow-plugin-catalog'; +export function deploymentEvidenceIsValid(dependency: FlowPluginActivationDependency): boolean; +export function validateRecommendedExtensions(recommendedCatalog: unknown, pluginCatalog: unknown): void; diff --git a/web/scripts/verify-catalog-gates.mjs b/web/scripts/verify-catalog-gates.mjs new file mode 100644 index 0000000..b6db178 --- /dev/null +++ b/web/scripts/verify-catalog-gates.mjs @@ -0,0 +1,158 @@ +import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from '../lib/flow-plugin-implementation-prs.mjs'; +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 [pluginCatalog, recommendedCatalog] = await Promise.all([ + readFile(pluginCatalogUrl, 'utf8').then(JSON.parse), + readFile(recommendedCatalogUrl, 'utf8').then(JSON.parse), +]); + +const REQUIRED_PLUGIN_DEPENDENCIES = new Map([ + ['babysitter', new Map([ + ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'], + ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'], + ])], +]); +const SHA = /^[0-9a-f]{40}$/; +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; + +function fail(message) { + throw new Error(`catalog dependency gate: ${message}`); +} + +function timestampMillis(value) { + if (!ISO_TIMESTAMP.test(value)) return null; + const milliseconds = Date.parse(value); + if (!Number.isFinite(milliseconds)) return null; + const normalized = value.includes('.') ? value : value.replace(/Z$/, '.000Z'); + return new Date(milliseconds).toISOString() === normalized ? milliseconds : null; +} + +export function deploymentEvidenceIsValid(dependency) { + const evidence = dependency.evidence; + if (evidence === null) return false; + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) return false; + if (Object.keys(evidence).sort().join(',') !== 'deployedAt,deploymentUrl,mergedAt,mergedCommit,pullRequestUrl') return false; + if (!Object.values(evidence).every(value => typeof value === 'string')) return false; + if (!Object.hasOwn(FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS, dependency.id) + || evidence.pullRequestUrl !== FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS[dependency.id]) return false; + let pullRequestUrl; + let deploymentUrl; + try { + pullRequestUrl = new URL(evidence.pullRequestUrl); + deploymentUrl = new URL(evidence.deploymentUrl); + } catch { + return false; + } + const mergedAt = timestampMillis(evidence.mergedAt); + const deployedAt = timestampMillis(evidence.deployedAt); + return pullRequestUrl.origin === 'https://github.com' + && new RegExp(`^/${dependency.repository}/pull/[1-9][0-9]*$`).test(pullRequestUrl.pathname) + && SHA.test(evidence.mergedCommit) + && mergedAt !== null + && deploymentUrl.href === `https://api.github.com/repos/${dependency.repository}/deployments/${deploymentUrl.pathname.split('/').pop()}` + && /^[1-9][0-9]*$/.test(deploymentUrl.pathname.split('/').pop() ?? '') + && deployedAt !== null + && deployedAt >= mergedAt; +} + +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 (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`); + } + const seen = new Set(); + for (const dependency of gate.dependencies) { + if (!dependency || typeof dependency !== 'object' || Array.isArray(dependency)) fail(`${label} has an invalid dependency`); + if (Object.keys(dependency).sort().join(',') !== 'evidence,id,repository,requiredState') { + fail(`${label} dependency ${dependency.id ?? ''} has unknown fields`); + } + if (seen.has(dependency.id)) fail(`${label} repeats dependency ${dependency.id}`); + seen.add(dependency.id); + if (requiredDependencies.get(dependency.id) !== dependency.repository) { + fail(`${label} dependency ${dependency.id} has the wrong repository`); + } + if (dependency.requiredState !== 'merged-and-deployed') { + fail(`${label} dependency ${dependency.id} must require merged-and-deployed`); + } + } + const allDependenciesProven = gate.dependencies.every(deploymentEvidenceIsValid); + if (gate.state === 'ready' && !allDependenciesProven) { + fail(`${label} may be ready only when every dependency carries merge and deployment evidence`); + } +} + +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'); +} + +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'); + +/** Every published extension must use a supported, validated plugin contract. */ +export function validateRecommendedExtensions(recommendedCatalog, pluginCatalog) { + const plugins = new Map(); + for (const entry of pluginCatalog.plugins) { + if (plugins.has(entry.name)) fail(`plugin catalog repeats ${entry.name}`); + const requiredDependencies = REQUIRED_PLUGIN_DEPENDENCIES.get(entry.name); + if (!requiredDependencies) fail(`${entry.name} has no independently defined dependency contract`); + validateActivationGate(entry.activation, `plugin catalog ${entry.name}`, requiredDependencies); + plugins.set(entry.name, entry); + } + const flowIds = new Set(); + for (const flow of recommendedCatalog.flows) { + if (flowIds.has(flow.id)) fail(`recommended catalog repeats flow ${flow.id}`); + flowIds.add(flow.id); + if (flow.extensions === undefined) continue; + if (!Array.isArray(flow.extensions)) fail(`${flow.id} extensions must be an array`); + const extensionIds = new Set(); + for (const extension of flow.extensions) { + const label = `${flow.id} extension ${extension?.id ?? ''}`; + 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`); + } + } + } +} + +validateRecommendedExtensions(recommendedCatalog, pluginCatalog); + +if (plugin.runtime.package !== '@relayflows/sdk' + || plugin.runtime.version !== '2.0.31' + || plugin.runtime.release !== 'v2.0.31') { + fail('Babysitter runtime must identify published @relayflows/sdk v2.0.31'); +} + +// Shape checks alone are not deployment proof. Only ready entries need live receipts. +for (const entry of pluginCatalog.plugins) { + if (entry.activation.state !== 'ready') continue; + for (const dependency of entry.activation.dependencies) await verifyDeploymentReceipt(dependency); +} + +console.log('catalog gates: Babysitter metadata is pinned and activation is fail-closed'); diff --git a/web/scripts/verify-deployment-receipts.d.mts b/web/scripts/verify-deployment-receipts.d.mts new file mode 100644 index 0000000..6e13634 --- /dev/null +++ b/web/scripts/verify-deployment-receipts.d.mts @@ -0,0 +1,5 @@ +import type { FlowPluginActivationDependency } from '../lib/flow-plugin-catalog'; +export function verifyDeploymentReceipt( + dependency: FlowPluginActivationDependency, + request?: typeof fetch, +): Promise; diff --git a/web/scripts/verify-deployment-receipts.mjs b/web/scripts/verify-deployment-receipts.mjs new file mode 100644 index 0000000..96638ea --- /dev/null +++ b/web/scripts/verify-deployment-receipts.mjs @@ -0,0 +1,58 @@ +import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from '../lib/flow-plugin-implementation-prs.mjs'; + +const REPOSITORIES = new Map([ + ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'], + ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'], +]); + +/** Read-only, public GitHub receipts. Missing/private/rate-limited proof fails closed. */ +export async function verifyDeploymentReceipt(dependency, request = fetch) { + const repository = REPOSITORIES.get(dependency.id); + const evidence = dependency.evidence; + if (!repository || dependency.repository !== repository || !evidence) { + throw new Error('dependency has no supported receipt contract'); + } + if (!Object.hasOwn(FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS, dependency.id) + || evidence.pullRequestUrl !== FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS[dependency.id]) { + throw new Error('PR receipt does not identify the declared capability implementation'); + } + const prPrefix = `https://github.com/${repository}/pull/`; + const deploymentPrefix = `https://api.github.com/repos/${repository}/deployments/`; + const id = (value, prefix) => typeof value === 'string' && value.startsWith(prefix) + && /^[1-9][0-9]*$/.test(value.slice(prefix.length)) ? value.slice(prefix.length) : null; + const pull = id(evidence.pullRequestUrl, prPrefix); + const deployment = id(evidence.deploymentUrl, deploymentPrefix); + if (!pull || !deployment) throw new Error('receipt must identify a PR and GitHub deployment in the required repository'); + const api = `https://api.github.com/repos/${repository}`; + const read = async url => { + const response = await request(url, { + headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + redirect: 'error', signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`dependency receipt unavailable: HTTP ${response.status}`); + return response.json(); + }; + const pr = await read(`${api}/pulls/${pull}`); + if (pr.merged !== true || pr.base?.repo?.full_name !== repository || pr.base?.ref !== 'main' + || pr.html_url !== evidence.pullRequestUrl || pr.merge_commit_sha !== evidence.mergedCommit + || pr.merged_at !== evidence.mergedAt) { + throw new Error('PR receipt does not prove the declared merge into main'); + } + const receipt = await read(`${deploymentPrefix}${deployment}`); + if (receipt.url !== evidence.deploymentUrl || receipt.sha !== evidence.mergedCommit + || receipt.repository_url !== api || receipt.environment !== 'production' + || receipt.production_environment !== true || receipt.transient_environment !== false + || !Number.isFinite(Date.parse(receipt.created_at)) + || Date.parse(receipt.created_at) < Date.parse(evidence.mergedAt)) { + throw new Error('deployment receipt does not prove this merged commit in production'); + } + // GitHub lists the newest status first; a past success followed by failure/inactive is insufficient. + const statuses = await read(`${deploymentPrefix}${deployment}/statuses?per_page=1`); + const latest = Array.isArray(statuses) ? statuses[0] : null; + if (!latest || latest.state !== 'success' || latest.environment !== 'production' + || latest.deployment_url !== evidence.deploymentUrl || latest.repository_url !== api + || latest.created_at !== evidence.deployedAt + || Date.parse(latest.created_at) < Date.parse(receipt.created_at)) { + throw new Error('latest deployment status does not prove the declared successful deployment'); + } +} diff --git a/web/scripts/verify-plugin-artifacts.d.mts b/web/scripts/verify-plugin-artifacts.d.mts new file mode 100644 index 0000000..d43d60e --- /dev/null +++ b/web/scripts/verify-plugin-artifacts.d.mts @@ -0,0 +1,6 @@ +import type { FlowPluginCatalogEntry } from '../lib/flow-plugin-catalog'; +export function assertPluginArtifact( + plugin: Pick, + files: { path: string; data: Buffer }[], +): void; +export function verifyPluginArtifact(plugin: FlowPluginCatalogEntry): Promise; diff --git a/web/scripts/verify-plugin-artifacts.mjs b/web/scripts/verify-plugin-artifacts.mjs new file mode 100644 index 0000000..4369e31 --- /dev/null +++ b/web/scripts/verify-plugin-artifacts.mjs @@ -0,0 +1,65 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +/** Match the SDK payloadManifest: path-sorted entries with canonical object keys. */ +export function assertPluginArtifact(plugin, files) { + const manifest = files.find(file => file.path === 'flows-plugin.json'); + if (!manifest) throw new Error(`${plugin.name}: flows-plugin.json is missing`); + const entries = [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) + .map(file => ({ bytes: file.data.length, path: file.path, sha256: sha256(file.data) })); + if (sha256(JSON.stringify(entries)) !== plugin.digest) { + throw new Error(`${plugin.name}: artifact digest mismatch`); + } + if (sha256(manifest.data) !== plugin.manifestSha256) { + throw new Error(`${plugin.name}: manifest digest mismatch`); + } +} + +/** Fetch the immutable commit, enumerate its actual directory, and hash every file. */ +export async function verifyPluginArtifact(plugin) { + const { owner, repo, path } = plugin.source; + if (![owner, repo].every(value => typeof value === 'string' && /^[A-Za-z0-9_.-]+$/.test(value)) + || typeof plugin.ref !== 'string' || !/^[0-9a-f]{40}$/.test(plugin.ref) + || typeof path !== 'string' || !path || path.startsWith('/') + || path.split('/').some(part => !part || part === '.' || part === '..')) { + throw new Error(`${plugin.name}: invalid artifact coordinates`); + } + const directory = await mkdtemp(join(tmpdir(), 'catalog-artifact-')); + const git = (...args) => exec('git', args, { + cwd: directory, timeout: 60_000, maxBuffer: 4_000_000, encoding: 'buffer', + }); + try { + await git('init', '--quiet'); + await git('fetch', '--quiet', '--depth=1', '--no-tags', + `https://github.com/${owner}/${repo}.git`, plugin.ref); + const { stdout: head } = await git('rev-parse', 'FETCH_HEAD'); + if (head.toString().trim() !== plugin.ref) throw new Error(`${plugin.name}: fetched commit mismatch`); + const prefix = `${path}/`; + const { stdout } = await git('ls-tree', '-r', '-z', '--long', plugin.ref, '--', path); + const records = stdout.toString().split('\0').filter(Boolean); + if (!records.length || records.length > 500) throw new Error(`${plugin.name}: missing or oversized artifact`); + const files = []; + let total = 0; + for (const record of records) { + const match = /^(100644|100755) blob ([0-9a-f]{40}) +([0-9]+)\t(.+)$/s.exec(record); + if (!match || !match[4].startsWith(prefix)) throw new Error(`${plugin.name}: artifact must contain plain files`); + const size = Number(match[3]); + total += size; + if (size > 256_000 || total > 2_000_000) throw new Error(`${plugin.name}: artifact exceeds size limit`); + const { stdout: data } = await git('cat-file', 'blob', match[2]); + if (data.length !== size) throw new Error(`${plugin.name}: blob size mismatch`); + files.push({ path: match[4].slice(prefix.length), data }); + } + assertPluginArtifact(plugin, files); + console.log(`${plugin.name}: verified ${plugin.ref}#${path} sha256:${plugin.digest}`); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/web/scripts/verify-recommended-flow-catalog.mjs b/web/scripts/verify-recommended-flow-catalog.mjs index 15246f7..68c8442 100644 --- a/web/scripts/verify-recommended-flow-catalog.mjs +++ b/web/scripts/verify-recommended-flow-catalog.mjs @@ -1,3 +1,4 @@ +import { verifyPluginArtifact } from './verify-plugin-artifacts.mjs'; import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; @@ -9,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 !== 2 || !Array.isArray(catalog.flows)) { - throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 2, with a flows array'); +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'); } for (const flow of catalog.flows) { @@ -69,3 +70,6 @@ for (const flow of catalog.flows) { assertRecommendedFlowSourceContract(flow, Buffer.concat(chunks).toString('utf8')); console.log(`${flow.id}: verified ${source.release} (${source.ref}) sha256:${actualHash}`); } + +const plugins = JSON.parse(await readFile(new URL('../data/flow-plugin-catalog.v1.json', import.meta.url), 'utf8')); +for (const plugin of plugins.plugins) await verifyPluginArtifact(plugin);