diff --git a/ops/BACKLOG.md b/ops/BACKLOG.md index 3991bbc3..bc203543 100644 --- a/ops/BACKLOG.md +++ b/ops/BACKLOG.md @@ -28,3 +28,10 @@ gate's needs. Not commitments; ordering is the Lead's call with evidence. and the relayflow review swarm (hoopsheet pattern), pushes fixes, replies, and closes itself at merge. The flow IS the PR's lifecycle. Khaliq, 2026-08-27 — natural first real consumer of `on()` + relayfile PR trees. +- **Regression suite (`regressions/`, dormant):** red/green flow pairs for the + four platform bugs found 2026-08-27 — enrollment-token bearer auth + (cloud#3202), the `--daemon` `$bunfs` argv re-exec, RelayCron's `succeeded` + into a void (covenant 2), and the cross-account 404 rendered as a permissions + error (covenant 1). Written in the v2 dialect against a surface that does not + exist yet; nothing runs until gates 1/2/6/7/8 close per `regressions/MANIFEST.json`. + The Garden should adopt them once flows run in cloud. diff --git a/regressions/MANIFEST.json b/regressions/MANIFEST.json new file mode 100644 index 00000000..68715434 --- /dev/null +++ b/regressions/MANIFEST.json @@ -0,0 +1,138 @@ +{ + "version": "0.1.0", + "suite": "regressions", + "authoredOn": "2026-08-27", + "dialect": "flows-v2 (docs/SURFACE.md)", + "dormant": true, + "dormancy": "No flow here declares an `on()` trigger, no schedule is registered, nothing is deployed, and nothing references this directory from a drive loop or CI job. The suite runs only when a human or the Garden invokes `flows run` explicitly.", + "adoptBy": "software-garden", + "adoptWhen": "flows run in cloud and the gates listed per pair have closed; the Garden should then run each red case (expect pass) and each green case (expect fail) as the standing proof the bug is still open, and flip the pair to green-only once the fix lands.", + "typecheck": { + "command": "cd sdk && npx tsc -p ../regressions/tsconfig.json", + "optIn": true, + "note": "Not part of `npm test`. Types come from regressions/surface.d.ts, a declaration-only slice of the v2 surface; delete it once @relayflows/surface exists." + }, + "surfaceGaps": [ + { + "id": "declared-failure-assertion", + "need": "A red case must assert that an operation fails in a declared way. The surface has no `expect(kind)` form, so every red probe is a deterministic step that always exits 0 and prints the observed status/body for a postfix gate to judge.", + "blocks": ["enrollment-token-bearer-auth", "cross-account-workspace-404"] + }, + { + "id": "rendered-ui-assertion", + "need": "No browser helper, so `the UI shows this string` is not assertable. The cross-account pair asserts the API-level cause instead: the response body carries no code the client could branch on.", + "blocks": ["cross-account-workspace-404"] + }, + { + "id": "darwin-arm64-placement", + "need": "gate 7's sandbox router has no macOS pool; the bun-compiled argv bug does not reproduce under node or `bun run`.", + "blocks": ["worker-daemon-bun-argv"] + }, + { + "id": "multi-principal-run", + "need": "Two or three credential principals inside one run (CLI bearer, browser session, in-account non-owner). Expressed as `principals/` mount paths and an `as:` argument; gate 8 must make that real and non-ambient.", + "blocks": ["enrollment-token-bearer-auth", "cross-account-workspace-404"] + } + ], + "pairs": [ + { + "slug": "enrollment-token-bearer-auth", + "bug": "POST /api/v1/workers/enrollment-tokens gates on requireSessionAuth, so a valid CLI bearer is answered 403 and worker enrollment has no headless/self-host/CI path.", + "red": "enrollment-token-bearer-auth.red.flow.ts", + "green": "enrollment-token-bearer-auth.green.flow.ts", + "upstream": "cloud#3202", + "requiredGates": ["gate-1", "gate-6", "gate-8"], + "blockedUntil": [ + "gate-1: a relayflow can run at all", + "gate-6: a relayfile adapter for AgentWorkforce cloud (f.cloud helper namespace)", + "gate-8: per-principal scoped credentials resolved through the proxy, not ambient env" + ], + "dependsOn": [], + "evidence": { + "files": [ + "AgentWorkforce/cloud packages/web/app/api/v1/workers/enrollment-tokens/route.ts:113-115" + ], + "strings": ["{\"error\":\"Forbidden\"} (HTTP 403)"], + "observedOn": "2026-08-27" + } + }, + { + "slug": "worker-daemon-bun-argv", + "bug": "`cloud worker start --daemon` re-execs the bun-compiled binary with argv[1] = /$bunfs/root/agent-relay-darwin-arm64; the child dies instantly while the CLI prints 'Cloud worker daemon started: '.", + "red": "worker-daemon-bun-argv.red.flow.ts", + "green": "worker-daemon-bun-argv.green.flow.ts", + "upstream": "to file — relay CLI", + "requiredGates": ["gate-1", "gate-6", "gate-7"], + "blockedUntil": [ + "gate-1: a relayflow can run at all", + "gate-6: cloud helper for worker liveness/heartbeat reads", + "gate-7: a darwin-arm64 sandbox profile — the bug is invisible under node or `bun run`" + ], + "dependsOn": ["enrollment-token-bearer-auth"], + "evidence": { + "files": [ + "AgentWorkforce/relay packages/cli/src/cli/commands/cloud-worker.ts:266", + "AgentWorkforce/relay packages/cli/src/cli/commands/cloud-worker.ts:279", + "AgentWorkforce/relay packages/cli/src/cli/commands/cloud-worker.ts:396" + ], + "strings": [ + "error: unknown command '/$bunfs/root/agent-relay-darwin-arm64'", + "Cloud worker daemon started: " + ], + "observedOn": "2026-08-27" + } + }, + { + "slug": "cron-succeeded-into-void", + "bug": "RelayCron marks a fired schedule lastTriggerStatus: 'succeeded' as soon as the launch POST returns a runId, even with no worker to claim it — a covenant-2 'succeeded' that did nothing.", + "red": "cron-succeeded-into-void.red.flow.ts", + "green": "cron-succeeded-into-void.green.flow.ts", + "upstream": "to file — cloud RelayCron", + "requiredGates": ["gate-1", "gate-2", "gate-6"], + "blockedUntil": [ + "gate-1: a relayflow can run at all", + "gate-2: the trigger plane, its executor-liveness check and sweep", + "gate-6: cloud helper for schedules, workers and run journals" + ], + "dependsOn": ["enrollment-token-bearer-auth"], + "evidence": { + "files": [ + "AgentWorkforce/cloud packages/web/app/api/v1/workflows/schedules/trigger/route.ts:178-192" + ], + "runIds": ["8e3e5916", "740c3a27"], + "schedule": "flows-drive", + "strings": ["lastTriggerStatus: \"succeeded\" with no run artifacts, no branch, no PR"], + "observedOn": "2026-08-27" + } + }, + { + "slug": "cross-account-workspace-404", + "bug": "A workspace belonging to another account answers 404 with no code and no user-facing message; the client maps every non-ok response to 'Check your workspace permissions', so a not-found condition is reported as a permissions problem.", + "red": "cross-account-workspace-404.red.flow.ts", + "green": "cross-account-workspace-404.green.flow.ts", + "upstream": "to file — cloud", + "requiredGates": ["gate-1", "gate-6", "gate-8"], + "blockedUntil": [ + "gate-1: a relayflow can run at all", + "gate-6: a relayfile adapter for AgentWorkforce cloud", + "gate-8: three principals in one run (owner session, other-account session, in-account non-owner)" + ], + "dependsOn": [], + "evidence": { + "files": [ + "AgentWorkforce/cloud packages/web/app/api/v1/workers/enrollment-tokens/route.ts:29-32", + "AgentWorkforce/cloud packages/web/components/workers/NewWorkerForm.tsx:91-92" + ], + "workspaces": { + "khaliq@agentrelay.com": "50587328-441d-4acb-b8f3-dbe1b3c5de99", + "khaliqgant@gmail.com": "0fb35c2e-861f-4d44-848e-fa3f5a3e192e" + }, + "strings": [ + "{\"error\":\"Workspace not found\"} (HTTP 404)", + "Could not create an enrollment token. Check your workspace permissions and try again." + ], + "observedOn": "2026-08-27" + } + } + ] +} diff --git a/regressions/README.md b/regressions/README.md new file mode 100644 index 00000000..be283530 --- /dev/null +++ b/regressions/README.md @@ -0,0 +1,88 @@ +# regressions — executable bug reports, red then green + +Four platform bugs were found on 2026-08-27, during the first dogfood runs. Each +one is written here twice, in the flows v2 dialect (`docs/SURFACE.md`): + +- **`.red.flow.ts`** — reproduces the failure. Its gates assert the + **broken** behaviour, so it **passes while the bug is present**. This is the + bug report, in a form a machine can re-run. +- **`.green.flow.ts`** — asserts the **corrected** behaviour. It **fails + while the bug is present** and passes once it is fixed. This is the acceptance + test. + +A bug is closed when its red case starts failing and its green case starts +passing, in the same run. Either one alone can lie: a green test that never ran +red proves nothing about the bug it claims to cover. + +## These flows do not run yet, and must not + +Nothing in this directory is wired into a drive loop, a schedule, or CI. No flow +declares an `on()` trigger, none is deployed, and nothing outside `regressions/` +references it except one backlog line in `ops/BACKLOG.md`. They are written +against `@relayflows/surface` — the v2 authoring surface, which does not exist +yet. `regressions/surface.d.ts` is a declaration-only slice of it: the exact +shapes these four pairs need, so the file doubles as a requirements list for +gate-1 SDK work. Delete it when the real surface ships. + +## Running them, once the kernel can + +```sh +flows check regressions/.red.flow.ts # preflight: refuses on a missing gate +flows run regressions/.red.flow.ts # expected: PASS while the bug is open +flows run regressions/.green.flow.ts # expected: FAIL while the bug is open +``` + +Opt-in typecheck (deliberately *not* part of `cd sdk && npm test`): + +```sh +cd sdk && npx tsc -p ../regressions/tsconfig.json +``` + +`MANIFEST.json` carries the same table in machine-readable form — slug, required +gates, `blockedUntil`, `dependsOn` — so the Garden can pick each pair up +automatically the moment its gates close. + +## The suite + +| slug | bug | evidence | gates required to run | upstream | +|---|---|---|---|---| +| `enrollment-token-bearer-auth` | Minting an enrollment token gates on `requireSessionAuth`, so a valid CLI bearer gets 403 — worker enrollment has no headless, self-host, or CI path. | `cloud packages/web/app/api/v1/workers/enrollment-tokens/route.ts:113-115`; response `{"error":"Forbidden"}` (403) | gate-1, gate-6, gate-8 | cloud#3202 | +| `worker-daemon-bun-argv` | `cloud worker start --daemon` re-execs the bun-compiled binary with `process.argv[1]`, which is a virtual `$bunfs` path; the child dies at once while the CLI reports success. | `relay packages/cli/src/cli/commands/cloud-worker.ts:266` (`process.argv[1] ?? 'agent-relay'`), `:279` (spawn `process.execPath`), `:396` (success log); `error: unknown command '/$bunfs/root/agent-relay-darwin-arm64'` | gate-1, gate-6, gate-7 | to file — relay CLI | +| `cron-succeeded-into-void` | A fired schedule is marked `lastTriggerStatus: "succeeded"` as soon as the launch POST returns a runId, even with no worker to claim it. A schedule can be silently zero forever. | `cloud packages/web/app/api/v1/workflows/schedules/trigger/route.ts:178-192`; runs `8e3e5916` and `740c3a27` on schedule `flows-drive` — both succeeded, nothing executed, no branch, no PR | gate-1, gate-2, gate-6 | to file — cloud RelayCron | +| `cross-account-workspace-404` | Another account's workspace answers 404 with no code and no user message; the client renders every non-ok response as a permissions problem. | `cloud .../enrollment-tokens/route.ts:29-32` (404 `{"error":"Workspace not found"}`), `cloud packages/web/components/workers/NewWorkerForm.tsx:91-92`; workspaces `50587328-…` (khaliq@agentrelay.com) vs `0fb35c2e-…` (khaliqgant@gmail.com) | gate-1, gate-6, gate-8 | to file — cloud | + +Two of these are covenant violations, not merely defects: +`cron-succeeded-into-void` is covenant 2 verbatim — *"a 'succeeded' that did +nothing is by definition a kernel bug"* — and so, in its own way, is +`worker-daemon-bun-argv`: the CLI reports a pid for a process that is already +gone. `cross-account-workspace-404` is covenant 1: the error names the wrong +condition in the user's vocabulary, so the user retries with permissions they +already have. + +## What the dialect cannot say yet + +Written down here rather than worked around silently, because each one is a real +gap in the surface: + +1. **No declared-failure assertion.** A red case must say "this operation fails, + in this declared way". The surface has `.gate()` — which *causes* a typed + failure — but no `expect(kind)` which *asserts* one. Every red probe is + therefore a deterministic step that always exits 0 and prints + `\n` (see `probe.ts`), letting a postfix gate judge the observed + response. It works, but the shape it wants is a first-class negative gate. + This belongs with the gate-1 verification work. +2. **No browser helper**, so "the UI shows this string" is not assertable. + `cross-account-workspace-404` asserts the API-level cause instead — the body + carries no `code` the client could branch on — and its green case moves the + user-facing message to the API, where the condition is actually known. +3. **No macOS placement.** `worker-daemon-bun-argv` only reproduces on the + darwin-arm64 compiled binary; the sandbox router (gate 7) has no such pool. + The pair declares `workspace: "sandbox:darwin-arm64"` and waits. +4. **Multi-principal runs are declarative only.** These flows need two or three + credential principals in one run. They are expressed as + `mnt/agentworkforce-cloud/principals//token` mount reads plus an `as:` + argument on each helper verb — the filesystem path *is* the permission — but + gate 8 has to make that real and non-ambient before any of it holds. +5. **Durable waiting is a helper, not a verb.** `f.cloud.workers.awaitHeartbeat` + compiles to a kernel wait, which the plugin contract permits. If waiting + turns out to be common enough in authored flows, it wants a name of its own. diff --git a/regressions/cron-succeeded-into-void.green.flow.ts b/regressions/cron-succeeded-into-void.green.flow.ts new file mode 100644 index 00000000..86a5d6c6 --- /dev/null +++ b/regressions/cron-succeeded-into-void.green.flow.ts @@ -0,0 +1,103 @@ +// GREEN — a schedule reports success only when work actually executed. +// +// BUG see cron-succeeded-into-void.red.flow.ts +// EVIDENCE packages/web/app/api/v1/workflows/schedules/trigger/route.ts:178-192; +// runs 8e3e5916 and 740c3a27 (schedule flows-drive, 2026-08-27) +// UPSTREAM to file — cloud RelayCron +// +// Two phases, because the fix has two halves: +// 1. with no eligible worker, the trigger reports a distinct non-success state +// (`no_executor`) — covenant 2's preflight: a trigger with no executor is +// refused before the run starts, not blessed after it; +// 2. with a worker, it reports `succeeded` AND the run's journal shows steps. +// +// This flow FAILS while the bug is present (phase 1 sees `succeeded`) and PASSES +// once both halves hold. +// +// RUN-WHEN: gate-1, gate-2, gate-6 +// DEPENDS-ON: enrollment-token-bearer-auth (phase 2 needs headless enrolment) + +import { flow } from "@relayflows/surface"; + +const WORKSPACE = "50587328-441d-4acb-b8f3-dbe1b3c5de99"; + +export default flow( + "regressions/cron-succeeded-into-void.green", + { + identity: "regressions/cron-succeeded-into-void", + tools: { relayfile: ["agentworkforce-cloud"] }, + budget: "$0.20/run", + }, + async (f) => { + const schedule = await f.cloud.schedules + .create({ + workspaceId: WORKSPACE, + workflow: "regressions/noop", + cron: "0 0 1 1 *", + name: "regression-cron-green", + as: "cli-bearer", + }) + .gate((s) => s.id.length > 0, "a disposable schedule"); + + // Phase 1 — no executor. + await f.cloud.workers + .list({ workspaceId: WORKSPACE, as: "cli-bearer" }) + .gate((w) => w.online.length === 0, "precondition: no executor"); + + await f.cloud.schedules.fire({ scheduleId: schedule.id, as: "cli-bearer" }); + + await f.cloud.schedules + .get({ scheduleId: schedule.id, as: "cli-bearer" }) + .gate( + (s) => s.lastTriggerStatus === "no_executor", + "with no eligible worker the trigger names that condition", + ) + .gate( + (s) => s.lastTriggerStatus !== "succeeded", + "and never claims success for work that could not start", + ) + .gate( + (s) => (s.lastTriggerError ?? "").length > 0, + "and leaves an error a human can read", + ); + + // Phase 2 — a real executor. Enrolment is headless, which is why this flow + // depends on cloud#3202 being fixed first. + const enrolment = await f.cloud.workers.mintEnrollmentToken({ + workspaceId: WORKSPACE, + name: "regression-cron-worker", + as: "cli-bearer", + }); + + await f + .run( + `agent-relay cloud worker register --workspace ${WORKSPACE}` + + ` --token-file ${enrolment.tokenPath} --name regression-cron-worker --json` + + " && agent-relay cloud worker start --daemon", + ) + .gate((out) => out.includes("daemon started"), "an executor now exists"); + + await f.cloud.schedules.fire({ scheduleId: schedule.id, as: "cli-bearer" }); + + const state = await f.cloud.schedules + .get({ scheduleId: schedule.id, as: "cli-bearer" }) + .gate( + (s) => s.lastTriggerStatus === "succeeded", + "with an executor, succeeded is now truthful", + ); + + await f.cloud.runs + .journal({ runId: state.lastTriggeredRunId ?? "", as: "cli-bearer" }) + .gate( + (j) => j.steps.length > 0, + "and succeeded is backed by a journal with executed steps", + ) + .gate( + (j) => j.steps.every((step) => step.completionReason !== null), + "every step carrying a completionReason (AGENTS.md rule 4)", + ); + + await f.cloud.schedules.remove({ scheduleId: schedule.id, as: "cli-bearer" }); + return f.done("bug_fixed"); + }, +); diff --git a/regressions/cron-succeeded-into-void.red.flow.ts b/regressions/cron-succeeded-into-void.red.flow.ts new file mode 100644 index 00000000..5e124df1 --- /dev/null +++ b/regressions/cron-succeeded-into-void.red.flow.ts @@ -0,0 +1,79 @@ +// RED — RelayCron reports `succeeded` for a schedule that executed nothing. +// +// BUG The trigger route marks a fired schedule +// `lastTriggerStatus: "succeeded"` as soon as the launch POST is +// accepted and returns a runId. Acceptance is not execution: with no +// worker enrolled the run is never claimed, so the schedule reports +// success while producing no journal, no branch, no PR. A schedule can +// be silently zero forever. +// EVIDENCE AgentWorkforce/cloud +// packages/web/app/api/v1/workflows/schedules/trigger/route.ts:178-192 +// if (launchResponse.ok && typeof launchBody?.runId === "string") { +// ... lastTriggerStatus: "succeeded" +// Observed twice on 2026-08-27, schedule `flows-drive`: +// runs 8e3e5916 and 740c3a27 — both `succeeded`, nothing executed. +// RFC-0001 covenant 2: "a 'succeeded' that did nothing is by +// definition a kernel bug". +// UPSTREAM to file — cloud RelayCron +// +// This flow PASSES while the bug is present. It uses its own disposable +// schedule so it never touches the production `flows-drive` schedule. +// +// RUN-WHEN: gate-1, gate-2, gate-6 + +import { flow } from "@relayflows/surface"; + +/** A workspace deliberately kept free of workers for this regression. */ +const WORKSPACE = "50587328-441d-4acb-b8f3-dbe1b3c5de99"; + +export default flow( + "regressions/cron-succeeded-into-void.red", + { + identity: "regressions/cron-succeeded-into-void", + tools: { relayfile: ["agentworkforce-cloud"] }, + budget: "$0.05/run", + }, + async (f) => { + await f.cloud.workers + .list({ workspaceId: WORKSPACE, as: "cli-bearer" }) + .gate( + (w) => w.online.length === 0, + "precondition: no executor exists, so nothing can possibly run", + ); + + const schedule = await f.cloud.schedules + .create({ + workspaceId: WORKSPACE, + workflow: "regressions/noop", + cron: "0 0 1 1 *", + name: "regression-cron-void", + as: "cli-bearer", + }) + .gate((s) => s.id.length > 0, "a disposable schedule — never the real flows-drive one"); + + await f.cloud.schedules + .fire({ scheduleId: schedule.id, as: "cli-bearer" }) + .gate((r) => r.accepted, "the sweep accepts the tick"); + + const state = await f.cloud.schedules + .get({ scheduleId: schedule.id, as: "cli-bearer" }) + .gate( + (s) => s.lastTriggerStatus === "succeeded", + "the bug: succeeded, with no executor anywhere in the system", + ) + .gate( + (s) => s.lastTriggerError === null, + "and nothing was recorded that would let a human notice", + ); + + await f.cloud.runs + .journal({ runId: state.lastTriggeredRunId ?? "", as: "cli-bearer" }) + .gate( + (j) => j.steps.length === 0, + "and the run it claims to have succeeded has no executed steps at all", + ); + + await f.cloud.schedules.remove({ scheduleId: schedule.id, as: "cli-bearer" }); + return f.done("bug_reproduced"); + }, +); diff --git a/regressions/cross-account-workspace-404.green.flow.ts b/regressions/cross-account-workspace-404.green.flow.ts new file mode 100644 index 00000000..3af90505 --- /dev/null +++ b/regressions/cross-account-workspace-404.green.flow.ts @@ -0,0 +1,92 @@ +// GREEN — not-found and forbidden are distinguishable, and each names its own +// condition. +// +// BUG see cross-account-workspace-404.red.flow.ts +// EVIDENCE AgentWorkforce/cloud +// packages/web/app/api/v1/workers/enrollment-tokens/route.ts:29-32 +// (404 {"error":"Workspace not found"} — no code, no user message) +// packages/web/components/workers/NewWorkerForm.tsx:91-92 +// (every non-ok response → "Check your workspace permissions") +// UPSTREAM to file — cloud +// +// The fix moves the user-facing message to the API, where the condition is +// actually known: each response carries a stable `code` and a `userMessage` the +// client renders verbatim, so a wrong account never reads as a role problem. +// +// This flow FAILS while the bug is present (no code, no userMessage) and PASSES +// once the two conditions are distinguishable. +// +// RUN-WHEN: gate-1, gate-6, gate-8 (three principals in one run) + +import { flow } from "@relayflows/surface"; +import { field, probe, readProbe } from "./probe.js"; + +/** khaliqgant@gmail.com → workspace "Default" — the other account's. */ +const OTHER_ACCOUNT_WORKSPACE = "0fb35c2e-861f-4d44-848e-fa3f5a3e192e"; + +/** khaliq@agentrelay.com → workspace "Default" — this account's own. */ +const OWN_ACCOUNT_WORKSPACE = "50587328-441d-4acb-b8f3-dbe1b3c5de99"; + +export default flow( + "regressions/cross-account-workspace-404.green", + { + identity: "regressions/cross-account-workspace-404", + tools: { relayfile: ["agentworkforce-cloud"] }, + budget: "$0.05/run", + }, + async (f) => { + // Wrong account: not found, and it says so. + const notFound = await f + .run( + probe({ + method: "POST", + path: "/api/v1/workers/enrollment-tokens", + principal: "browser-session", + json: { workspaceId: OTHER_ACCOUNT_WORKSPACE, name: "regression-green" }, + }), + ) + .gate((out) => readProbe(out).status === 404, "a workspace of another account is not found") + .gate( + (out) => field(readProbe(out).body, "code") === "workspace_not_found", + "carrying a stable code the client can branch on", + ) + .gate( + (out) => !String(field(readProbe(out).body, "userMessage") ?? "").includes("permission"), + "and a message that does not blame permissions for a not-found condition", + ) + .gate( + (out) => String(field(readProbe(out).body, "userMessage") ?? "").length > 0, + "the message the user reads comes from the API, which is the only place the condition is known", + ); + + // Right account, insufficient role: forbidden, and it says that instead. + const forbidden = await f + .run( + probe({ + method: "POST", + path: "/api/v1/workers/enrollment-tokens", + principal: "browser-session-member", + json: { workspaceId: OWN_ACCOUNT_WORKSPACE, name: "regression-green" }, + }), + ) + .gate((out) => readProbe(out).status === 403, "an in-account non-owner is forbidden") + .gate( + (out) => field(readProbe(out).body, "code") === "insufficient_role", + "with its own code", + ) + .gate( + (out) => String(field(readProbe(out).body, "userMessage") ?? "").includes("owner"), + "and a message naming the role the caller lacks", + ); + + // The point of the whole pair: the two are not interchangeable. + await f + .run("printf ok") + .gate( + () => field(readProbe(notFound).body, "code") !== field(readProbe(forbidden).body, "code"), + "404 and 403 must be distinguishable by the client without guessing", + ); + + return f.done("bug_fixed"); + }, +); diff --git a/regressions/cross-account-workspace-404.red.flow.ts b/regressions/cross-account-workspace-404.red.flow.ts new file mode 100644 index 00000000..f9e9cc46 --- /dev/null +++ b/regressions/cross-account-workspace-404.red.flow.ts @@ -0,0 +1,88 @@ +// RED — a not-found workspace is reported to the user as a permissions problem. +// +// BUG Two accounts each own a workspace named "Default". Requesting the +// other account's workspace answers 404 {"error":"Workspace not found"} +// — a body with no machine-readable code and no user-facing message. +// The client maps EVERY non-ok response to one string, so a wrong +// account reads as a role problem and the user retries forever with +// permissions they already have. Covenant 1: an error message must name +// the author's mistake in the author's vocabulary. +// EVIDENCE AgentWorkforce/cloud +// packages/web/app/api/v1/workers/enrollment-tokens/route.ts:29-32 +// response: NextResponse.json({ error: "Workspace not found" }, { status: 404 }) +// packages/web/components/workers/NewWorkerForm.tsx:91-92 +// if (!response.ok) { +// setError("Could not create an enrollment token. Check your workspace permissions and try again."); +// Observed 2026-08-27: +// khaliq@agentrelay.com → workspace 50587328-441d-4acb-b8f3-dbe1b3c5de99 +// khaliqgant@gmail.com → workspace 0fb35c2e-861f-4d44-848e-fa3f5a3e192e +// UPSTREAM to file — cloud +// +// This flow PASSES while the bug is present. +// +// RUN-WHEN: gate-1, gate-6, gate-8 (two principals in one run) + +import { flow } from "@relayflows/surface"; +import { field, probe, readProbe } from "./probe.js"; + +/** khaliqgant@gmail.com → workspace "Default" — the other account's. */ +const OTHER_ACCOUNT_WORKSPACE = "0fb35c2e-861f-4d44-848e-fa3f5a3e192e"; + +/** khaliq@agentrelay.com → workspace "Default" — this account's own. */ +const OWN_ACCOUNT_WORKSPACE = "50587328-441d-4acb-b8f3-dbe1b3c5de99"; + +export default flow( + "regressions/cross-account-workspace-404.red", + { + identity: "regressions/cross-account-workspace-404", + tools: { relayfile: ["agentworkforce-cloud"] }, + budget: "$0.05/run", + }, + async (f) => { + await f + .run( + probe({ + method: "POST", + path: "/api/v1/workers/enrollment-tokens", + principal: "browser-session", + json: { workspaceId: OTHER_ACCOUNT_WORKSPACE, name: "regression-red" }, + }), + ) + .gate( + (out) => readProbe(out).status === 404, + "asking for another account's workspace is a not-found condition", + ) + .gate( + (out) => field(readProbe(out).body, "code") === undefined, + "the bug: the body carries no code, so the client cannot tell 404 from 403", + ) + .gate( + (out) => field(readProbe(out).body, "userMessage") === undefined, + "and no user-facing message, so the client invents one", + ); + + // The genuine role failure — an in-account member who is not an org owner — + // answers a body of the same shape. Two different conditions, one + // indistinguishable payload: that is why the client can only ever print one + // message, and why the message it prints is the wrong one half the time. + await f + .run( + probe({ + method: "POST", + path: "/api/v1/workers/enrollment-tokens", + principal: "browser-session-member", + json: { workspaceId: OWN_ACCOUNT_WORKSPACE, name: "regression-red" }, + }), + ) + .gate( + (out) => readProbe(out).status === 403, + "an in-account non-owner is a role condition", + ) + .gate( + (out) => field(readProbe(out).body, "code") === undefined, + "and carries no code either — the client sees two failures it cannot tell apart", + ); + + return f.done("bug_reproduced"); + }, +); diff --git a/regressions/enrollment-token-bearer-auth.green.flow.ts b/regressions/enrollment-token-bearer-auth.green.flow.ts new file mode 100644 index 00000000..e9eb77e8 --- /dev/null +++ b/regressions/enrollment-token-bearer-auth.green.flow.ts @@ -0,0 +1,67 @@ +// GREEN — cloud#3202: an org-owner CLI bearer can mint a usable enrolment token. +// +// BUG see enrollment-token-bearer-auth.red.flow.ts +// EVIDENCE packages/web/app/api/v1/workers/enrollment-tokens/route.ts:113-115 +// UPSTREAM cloud#3202 +// +// This flow FAILS while the bug is present (the mint step is answered 403) and +// PASSES once bearer auth is accepted for org owners. It is the acceptance test: +// minting is not enough — the token must actually enrol a worker. +// +// RUN-WHEN: gate-1, gate-6, gate-8 + +import { flow } from "@relayflows/surface"; + +/** khaliq@agentrelay.com → workspace "Default". */ +const WORKSPACE = "50587328-441d-4acb-b8f3-dbe1b3c5de99"; + +export default flow( + "regressions/enrollment-token-bearer-auth.green", + { + identity: "regressions/cloud-3202", + tools: { relayfile: ["agentworkforce-cloud"] }, + budget: "$0.05/run", + }, + async (f) => { + // The receipt carries a mount PATH, not the token: the mount write is the + // effect record (Appendix A rule 3), so no secret is journaled and no + // secret reaches a command line. + const enrolment = await f.cloud.workers + .mintEnrollmentToken({ + workspaceId: WORKSPACE, + name: "regression-green", + as: "cli-bearer", + }) + .gate( + (r) => r.tokenPath.startsWith("mnt/"), + "an org-owner CLI bearer must be able to mint — no browser session required", + ) + .gate( + (r) => Date.parse(r.expiresAt) > Date.now(), + "and the minted token must still be live when it is handed over", + ); + + // Minting is worthless if the token cannot enrol. The token is read from the + // mount by the CLI, never interpolated into the command. + const worker = await f + .run( + `agent-relay cloud worker register --workspace ${WORKSPACE}` + + ` --token-file ${enrolment.tokenPath} --name regression-green --json`, + ) + .gate( + (out) => typeof JSON.parse(out).workerId === "string", + "the minted token must be usable by `worker register` — the whole point of minting", + ); + + // The enrolment must be real on the cloud side too, not just locally + // recorded — a mint that produces an invisible worker is another void. + await f.cloud.workers + .list({ workspaceId: WORKSPACE, as: "cli-bearer" }) + .gate( + (w) => w.all.some((entry) => entry.workerId === JSON.parse(worker).workerId), + "and the enrolled worker is visible to the account that minted for it", + ); + + return f.done("bug_fixed"); + }, +); diff --git a/regressions/enrollment-token-bearer-auth.red.flow.ts b/regressions/enrollment-token-bearer-auth.red.flow.ts new file mode 100644 index 00000000..a6196252 --- /dev/null +++ b/regressions/enrollment-token-bearer-auth.red.flow.ts @@ -0,0 +1,73 @@ +// RED — cloud#3202: worker enrollment cannot be automated. +// +// BUG POST /api/v1/workers/enrollment-tokens gates on `requireSessionAuth`, +// so a valid CLI bearer token is answered 403 Forbidden. There is no +// headless, self-host, or CI path to enrol a worker — every worker must +// be born from a browser session. +// EVIDENCE AgentWorkforce/cloud +// packages/web/app/api/v1/workers/enrollment-tokens/route.ts:113-115 +// if (!requireSessionAuth(auth)) { +// return NextResponse.json({ error: "Forbidden" }, { status: 403 }); +// } +// Observed 2026-08-27: mint with `Authorization: Bearer ` +// → HTTP 403 {"error":"Forbidden"}. +// UPSTREAM cloud#3202 +// +// This flow PASSES while the bug is present — it is the executable bug report. +// Its green twin is the acceptance test. +// +// RUN-WHEN: gate-1, gate-6, gate-8 + +import { flow } from "@relayflows/surface"; +import { field, probe, readProbe } from "./probe.js"; + +/** khaliq@agentrelay.com → workspace "Default". */ +const WORKSPACE = "50587328-441d-4acb-b8f3-dbe1b3c5de99"; + +export default flow( + "regressions/enrollment-token-bearer-auth.red", + { + identity: "regressions/cloud-3202", + tools: { relayfile: ["agentworkforce-cloud"] }, + budget: "$0.05/run", + }, + async (f) => { + // The principal is an org owner holding a CLI bearer — the exact credential + // `agent-relay login` already writes, and the only one a headless host has. + await f + .run( + probe({ + method: "POST", + path: "/api/v1/workers/enrollment-tokens", + principal: "cli-bearer", + json: { workspaceId: WORKSPACE, name: "regression-red" }, + }), + ) + .gate( + (out) => readProbe(out).status === 403, + "the bug: a valid CLI bearer is refused, so enrolment has no headless path", + ) + .gate( + (out) => field(readProbe(out).body, "error") === "Forbidden", + "and the refusal is the session-auth Forbidden from route.ts:114", + ); + + // Same request, same org owner, from a browser session: this one works. + // The asymmetry is the whole bug, so the red case pins both halves. + await f + .run( + probe({ + method: "POST", + path: "/api/v1/workers/enrollment-tokens", + principal: "browser-session", + json: { workspaceId: WORKSPACE, name: "regression-red-session" }, + }), + ) + .gate( + (out) => readProbe(out).status === 200, + "the same principal succeeds with a session cookie — only the bearer is refused", + ); + + return f.done("bug_reproduced"); + }, +); diff --git a/regressions/probe.ts b/regressions/probe.ts new file mode 100644 index 00000000..a580f009 --- /dev/null +++ b/regressions/probe.ts @@ -0,0 +1,54 @@ +// Shared shell probe for the regression flows. +// +// A red flow has to judge a *response*, not be killed by it. The v2 surface has +// no "expect this declared failure" form (see README, "What the dialect cannot +// say yet"), so every probe is a deterministic step that always exits 0 and +// prints `\n`. The postfix `.gate()` then judges the observed +// response — verification as control flow, exactly as RFC-0001 §1 intends. +// +// Auth is a mount read, never an ambient env var (gate 8: no ambient env +// inheritance). The relayfile adapter for AgentWorkforce cloud exposes each +// principal's bearer at `/principals//token`, so the filesystem +// path IS the permission (SURFACE.md §2.3). + +export const CLOUD_MOUNT = "mnt/agentworkforce-cloud"; + +export interface ProbeResult { + status: number; + body: string; +} + +/** Build the deterministic command for one authenticated probe. */ +export function probe(input: { + method: string; + path: string; + /** Principal directory under `/principals/` — the credential scope. */ + principal: string; + json?: unknown; +}): string { + const payload = input.json === undefined ? "" : ` -d '${JSON.stringify(input.json)}'`; + return [ + "curl -sS -w '\\n%{http_code}'", + `-X ${input.method}`, + `"$(cat ${CLOUD_MOUNT}/base_url)${input.path}"`, + `-H "Authorization: Bearer $(cat ${CLOUD_MOUNT}/principals/${input.principal}/token)"`, + "-H 'Content-Type: application/json'", + payload, + ].join(" ").trim(); +} + +/** Split a probe step's stdout back into status + body. */ +export function readProbe(stdout: string): ProbeResult { + const lines = stdout.trimEnd().split("\n"); + const status = Number(lines.pop()); + return { status, body: lines.join("\n") }; +} + +/** Read one top-level field of a JSON response body; `undefined` if absent. */ +export function field(body: string, key: string): unknown { + try { + return (JSON.parse(body) as Record)[key]; + } catch { + return undefined; + } +} diff --git a/regressions/surface.d.ts b/regressions/surface.d.ts new file mode 100644 index 00000000..b12705da --- /dev/null +++ b/regressions/surface.d.ts @@ -0,0 +1,130 @@ +// The v2 authoring surface these regressions are written against. +// +// DECLARATION ONLY — no implementation exists yet. `@relayflows/surface` is +// what docs/SURFACE.md specifies and what gate-1 SDK work must produce. This +// file is deliberately the *narrow* slice the four regression pairs need, so it +// doubles as a requirements list: when the real surface exports these shapes, +// delete this file and the suite compiles against the SDK unchanged. +// +// Nothing here widens the kernel vocabulary. Three step verbs (run / llm / +// agent) + four resident verbs (on / human / dispatch / done); `f.cloud` is a +// helper namespace generated from a relayfile adapter (gate 6), and every verb +// on it compiles to a mount read/write or a wait (SURFACE.md §3). + +declare module "@relayflows/surface" { + /** A step result with its postfix verification gate (SURFACE.md §2 law 2). */ + export interface Step extends PromiseLike { + /** Fails the step with `gate_failed` when the predicate is false. */ + gate(predicate: (value: T) => boolean, because?: string): Step; + } + + /** Optional header — escalation only; the empty header is the common case. */ + export interface FlowHeader { + identity?: string; + memory?: { script?: boolean; agent?: boolean }; + budget?: string; + tools?: { relayfile?: string[]; mcp?: string[] }; + workspace?: string; + } + + export interface AgentResult { + summary: string; + artifacts: string[]; + } + + export interface WorkerSummary { + workerId: string; + status: string; + lastSeenAt: string | null; + } + + export interface Heartbeat { + workerId: string; + status: string; + lastSeenAt: string | null; + } + + export interface EnrollmentReceipt { + /** Mount path holding the plaintext token — never the token itself, so no + * secret is journaled (Appendix A rule 3: the mount write is the record). */ + tokenPath: string; + expiresAt: string; + registerCommand: string; + } + + export interface ScheduleState { + id: string; + lastTriggerStatus: string | null; + lastTriggeredRunId: string | null; + lastTriggerError: string | null; + } + + export interface JournalStep { + id: string; + type: "deterministic" | "llm" | "agent"; + completionReason: string | null; + } + + export interface RunJournal { + runId: string; + steps: JournalStep[]; + completionReason: string | null; + } + + /** Helper namespace generated from the AgentWorkforce cloud relayfile adapter. */ + export interface CloudHelper { + workers: { + mintEnrollmentToken(input: { + workspaceId: string; + name: string; + /** Credential scope to act under — `/principals/` (gate 8). */ + as: string; + }): Step; + list(input: { workspaceId: string; as: string }): Step<{ + online: WorkerSummary[]; + all: WorkerSummary[]; + }>; + heartbeat(input: { workerId: string; as: string }): Step; + /** Compiles to a durable wait — a legal plugin compile target (SURFACE.md §3). */ + awaitHeartbeat(input: { workerId: string; as: string; within: string }): Step; + }; + schedules: { + create(input: { + workspaceId: string; + workflow: string; + cron: string; + name: string; + as: string; + }): Step<{ id: string }>; + /** Force one sweep tick for this schedule. */ + fire(input: { scheduleId: string; as: string }): Step<{ accepted: boolean }>; + get(input: { scheduleId: string; as: string }): Step; + remove(input: { scheduleId: string; as: string }): Step<{ deleted: boolean }>; + }; + runs: { + journal(input: { runId: string; as: string }): Step; + }; + } + + /** The flow context: three step verbs, four resident verbs, helpers. */ + export interface Ctx { + run(command: string): Step; + llm(strings: TemplateStringsArray, ...values: unknown[]): Step; + agent(name: string, options: { task: string; workspace?: string }): Step; + human(question: string, options: { to: string }): Promise; + dispatch(flow: string, input: unknown): Promise; + done(reason: string): void; + cloud: CloudHelper; + } + + export interface FlowHandle { + readonly name: string; + } + + export function flow(name: string, body: (f: Ctx) => Promise): FlowHandle; + export function flow( + name: string, + header: FlowHeader, + body: (f: Ctx) => Promise, + ): FlowHandle; +} diff --git a/regressions/tsconfig.json b/regressions/tsconfig.json new file mode 100644 index 00000000..16572cd9 --- /dev/null +++ b/regressions/tsconfig.json @@ -0,0 +1,16 @@ +{ + "//": "OPT-IN typecheck for the dormant regression suite. Not referenced by sdk/package.json and not part of `npm test`. Run explicitly: cd sdk && npx tsc -p ../regressions/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": [] + }, + "include": ["*.ts"] +} diff --git a/regressions/worker-daemon-bun-argv.green.flow.ts b/regressions/worker-daemon-bun-argv.green.flow.ts new file mode 100644 index 00000000..4315bdce --- /dev/null +++ b/regressions/worker-daemon-bun-argv.green.flow.ts @@ -0,0 +1,61 @@ +// GREEN — `cloud worker start --daemon` starts a daemon that is alive and +// visible to cloud liveness. +// +// BUG see worker-daemon-bun-argv.red.flow.ts +// EVIDENCE packages/cli/src/cli/commands/cloud-worker.ts:266,279,396 +// UPSTREAM to file — relay CLI +// +// This flow FAILS while the bug is present (the daemon dies before the liveness +// gate) and PASSES once the re-exec resolves a real executable path instead of +// `process.argv[1]`. Reporting a pid is not the acceptance criterion; a worker +// that heartbeats is. +// +// RUN-WHEN: gate-1, gate-6, gate-7 (darwin-arm64 placement) + +import { flow } from "@relayflows/surface"; + +export default flow( + "regressions/worker-daemon-bun-argv.green", + { + identity: "regressions/worker-daemon-bun-argv", + tools: { relayfile: ["agentworkforce-cloud"] }, + workspace: "sandbox:darwin-arm64", + budget: "$0.05/run", + }, + async (f) => { + const started = await f + .run("agent-relay cloud worker start --daemon 2>&1") + .gate( + (out) => /Cloud worker daemon started: \d+/.test(out), + "the CLI reports a pid", + ); + + const pid = /Cloud worker daemon started: (\d+)/.exec(started)?.[1] ?? "0"; + + await f + .run(`ps -p ${pid} >/dev/null 2>&1 && echo alive || echo dead`) + .gate( + (state) => state.trim() === "alive", + "the reported pid must be a live process — the report must be true", + ); + + await f + .run("agent-relay cloud worker logs --tail 40 2>&1") + .gate( + (log) => !log.includes("unknown command"), + "and the daemon must not have re-execed itself into an unknown command", + ); + + // The durable half: local liveness is not enough, cloud has to see it. + // `awaitHeartbeat` compiles to a kernel wait, so this parks rather than polls. + await f.cloud.workers + .awaitHeartbeat({ workerId: "regression-daemon", as: "cli-bearer", within: "90s" }) + .gate( + (hb) => hb.lastSeenAt !== null && hb.status === "online", + "cloud liveness must observe the daemon the CLI said it started", + ); + + await f.run(`kill ${pid} 2>/dev/null || true`); + return f.done("bug_fixed"); + }, +); diff --git a/regressions/worker-daemon-bun-argv.red.flow.ts b/regressions/worker-daemon-bun-argv.red.flow.ts new file mode 100644 index 00000000..04ea30ff --- /dev/null +++ b/regressions/worker-daemon-bun-argv.red.flow.ts @@ -0,0 +1,71 @@ +// RED — `cloud worker start --daemon` reports a pid for a process that is +// already dead: the bun-compiled binary re-execs itself with a virtual +// `$bunfs` argv. +// +// BUG startDaemon() spawns `process.execPath` and passes `process.argv[1]` +// as the first argument. Under `bun build --compile`, execPath is the +// binary and argv[1] is the virtual path +// `/$bunfs/root/agent-relay-darwin-arm64`, so the child parses that +// path as a command name and exits immediately, while the parent has +// already printed success. +// EVIDENCE AgentWorkforce/relay packages/cli/src/cli/commands/cloud-worker.ts +// :266 `process.argv[1] ?? 'agent-relay',` +// :279 `child = input.deps.spawnProcess(process.execPath, args, {` +// :396 deps.log('Cloud worker daemon started: ' + daemonRecord.pid) +// Observed 2026-08-27 on the darwin-arm64 compiled binary: +// error: unknown command '/$bunfs/root/agent-relay-darwin-arm64' +// while the CLI printed "Cloud worker daemon started: ". +// Covenant 2: a "succeeded" that did nothing. +// UPSTREAM to file — relay CLI +// +// This flow PASSES while the bug is present. +// +// RUN-WHEN: gate-1, gate-6, gate-7 (darwin-arm64 placement) + +import { flow } from "@relayflows/surface"; + +export default flow( + "regressions/worker-daemon-bun-argv.red", + { + identity: "regressions/worker-daemon-bun-argv", + tools: { relayfile: ["agentworkforce-cloud"] }, + // gate 7: this step must land on a darwin-arm64 host running the compiled + // binary. The bug does not exist under `bun run` or under node. + workspace: "sandbox:darwin-arm64", + budget: "$0.05/run", + }, + async (f) => { + const started = await f + .run("agent-relay cloud worker start --daemon 2>&1") + .gate( + (out) => /Cloud worker daemon started: \d+/.test(out), + "the CLI claims the daemon started — this is the lie under test", + ); + + const pid = /Cloud worker daemon started: (\d+)/.exec(started)?.[1] ?? "0"; + + await f + .run(`ps -p ${pid} >/dev/null 2>&1 && echo alive || echo dead`) + .gate( + (state) => state.trim() === "dead", + "the bug: the pid the CLI reported is already gone", + ); + + await f + .run("agent-relay cloud worker logs --tail 40 2>&1") + .gate( + (log) => log.includes("unknown command '/$bunfs/root/agent-relay-darwin-arm64'"), + "and the daemon log names the cause: argv[1] is a $bunfs virtual path", + ); + + // No process means no heartbeat: the cloud side never sees this worker. + await f.cloud.workers + .heartbeat({ workerId: "regression-daemon", as: "cli-bearer" }) + .gate( + (hb) => hb.lastSeenAt === null, + "and cloud liveness never observes the worker the CLI said it started", + ); + + return f.done("bug_reproduced"); + }, +);