From 654644fbc6638b703f1dff70772a4e10f19a4767 Mon Sep 17 00:00:00 2001 From: BUILD3R-Dev Date: Sat, 17 Oct 2026 16:05:28 -0500 Subject: [PATCH 01/14] Implement native project knowledge, scoped agents and verified local workflow --- .github/workflows/ci.yml | 29 + .gitignore | 8 + README.md | 18 +- apps/cli/src/checkout.ts | 18 +- apps/cli/src/main.ts | 11 + apps/cli/src/mcp.ts | 40 +- apps/cli/src/native.ts | 118 ++++ apps/cli/src/submit.ts | 13 +- apps/cli/test/mcp.test.ts | 2 +- apps/cli/test/native.test.ts | 78 +++ apps/runner/src/g1-accepted-health.ts | 26 + apps/runner/src/g1-artifacts-sidecar.ts | 140 +++++ apps/runner/src/g1-bwrap-commands.ts | 17 + apps/runner/src/g1-candidate-host.mjs | 15 + apps/runner/src/g1-dev-worker.ts | 85 +++ apps/runner/src/g1-fresh-session.ts | 35 ++ apps/runner/src/g1-fresh-submit.ts | 43 ++ apps/runner/src/g1-local-bridge.ts | 77 +++ apps/runner/src/g1-local-preview.ts | 22 + apps/runner/src/g1-local-verifier.ts | 116 ++++ apps/runner/src/g1-pending-review.ts | 37 ++ apps/runner/src/g1-real-local.ts | 93 +++ apps/runner/src/g1-scheduling-harness.ts | 14 + apps/runner/src/g1-worker-rehearsal.ts | 141 +++++ apps/runner/test/g1-artifacts-sidecar.test.ts | 26 + apps/runner/test/g1-bwrap-commands.test.ts | 18 + apps/runner/test/g1-local-preview.test.ts | 15 + apps/runner/test/g1-local-verifier.test.ts | 31 + apps/runner/test/g1-real-local.test.ts | 15 + apps/web/e2e/local-stack.spec.ts | 6 +- apps/web/e2e/native.spec.ts | 126 ++++ apps/web/playwright.native.config.ts | 9 + apps/web/src/api/native.ts | 31 + apps/web/src/lib/router.ts | 6 +- apps/web/src/screens/AgentHandoff.tsx | 27 +- apps/web/src/screens/NativeReview.tsx | 52 ++ apps/web/src/screens/ProjectKnowledge.tsx | 53 ++ apps/web/src/screens/ProjectOutcomes.tsx | 15 + apps/web/src/screens/ProjectShell.tsx | 22 +- apps/web/test/native.test.tsx | 59 ++ apps/worker/API.md | 48 ++ apps/worker/e2e/real-preview.ts | 20 + apps/worker/e2e/real-stack.ts | 28 + .../0019_native_release_bindings.sql | 14 + apps/worker/src/app.ts | 6 + apps/worker/src/context.ts | 2 +- apps/worker/src/env.ts | 2 + apps/worker/src/ledger/errors.ts | 3 + apps/worker/src/ledger/org-ledger.ts | 231 ++++++++ apps/worker/src/ledger/schema.ts | 20 + apps/worker/src/ledger/sqlite-store.ts | 63 ++ apps/worker/src/lib/http.ts | 4 + apps/worker/src/local-artifacts.ts | 31 + apps/worker/src/middleware/authz.ts | 12 +- apps/worker/src/routes/native-payloads.ts | 25 + apps/worker/src/routes/native-projects.ts | 178 ++++++ apps/worker/src/routes/native-submit.ts | 52 ++ apps/worker/src/routes/orgs.ts | 4 + apps/worker/src/routes/projects.ts | 12 + apps/worker/src/routes/tasks.ts | 6 +- apps/worker/src/services/access.ts | 1 + apps/worker/src/services/native-payloads.ts | 44 ++ apps/worker/src/services/native-release.ts | 53 ++ apps/worker/src/services/releases.ts | 25 +- apps/worker/test/api/native-payloads.test.ts | 44 ++ apps/worker/test/api/native-projects.test.ts | 52 ++ apps/worker/test/api/native-release.test.ts | 44 ++ .../fault/native-acceptance.fault.test.ts | 31 + .../test/security/native-projects.test.ts | 32 ++ .../test/security/native-submit.test.ts | 45 ++ .../test/security/native-verification.test.ts | 19 + docs/LIMITATIONS.md | 33 ++ docs/SELF-HOSTING.md | 10 + docs/agent-native/README.md | 204 +++++++ docs/agent-native/agency-consumer.md | 87 +++ docs/agent-native/architecture.md | 269 +++++++++ docs/agent-native/authority-map.md | 38 ++ .../evidence/fresh-agent-implementation.diff | 13 + docs/agent-native/evidence/rehearsals.json | 542 ++++++++++++++++++ docs/agent-native/execution-status.md | 195 +++++++ docs/agent-native/images/knowledge.png | Bin 0 -> 168066 bytes docs/agent-native/images/outcomes.png | Bin 0 -> 83326 bytes docs/agent-native/images/preview.png | Bin 0 -> 14052 bytes docs/agent-native/images/review.png | Bin 0 -> 106604 bytes docs/agent-native/images/source-accepted.png | Bin 0 -> 92739 bytes docs/agent-native/images/source-review.png | Bin 0 -> 92739 bytes docs/agent-native/implementation-plan.md | 393 +++++++++++++ docs/agent-native/local-workflow.md | 85 +++ docs/agent-native/release-rehearsal.md | 45 ++ docs/agent-native/sol-6.1-goal.md | 88 +++ fixtures/scheduling/README.md | 57 ++ fixtures/scheduling/build.mjs | 4 + fixtures/scheduling/worker.mjs | 27 + package.json | 6 +- packages/core/src/index.ts | 1 + packages/core/src/project.test.ts | 74 +++ packages/core/src/project.ts | 208 +++++++ packages/ledger/src/index.ts | 2 + packages/ledger/src/project-bundle.test.ts | 60 ++ packages/ledger/src/project-bundle.ts | 133 +++++ packages/ledger/src/project.test.ts | 99 ++++ packages/ledger/src/project.ts | 248 ++++++++ packages/runner-protocol/src/index.ts | 1 + .../runner-protocol/src/project-scenarios.ts | 54 ++ .../test/project-scenarios.test.ts | 47 ++ scripts/dev-real-local.sh | 11 + scripts/dev-real-worker.sh | 10 + scripts/test-g1-local.sh | 37 ++ 108 files changed, 5899 insertions(+), 40 deletions(-) create mode 100644 apps/cli/src/native.ts create mode 100644 apps/cli/test/native.test.ts create mode 100644 apps/runner/src/g1-accepted-health.ts create mode 100644 apps/runner/src/g1-artifacts-sidecar.ts create mode 100644 apps/runner/src/g1-bwrap-commands.ts create mode 100644 apps/runner/src/g1-candidate-host.mjs create mode 100644 apps/runner/src/g1-dev-worker.ts create mode 100644 apps/runner/src/g1-fresh-session.ts create mode 100644 apps/runner/src/g1-fresh-submit.ts create mode 100644 apps/runner/src/g1-local-bridge.ts create mode 100644 apps/runner/src/g1-local-preview.ts create mode 100644 apps/runner/src/g1-local-verifier.ts create mode 100644 apps/runner/src/g1-pending-review.ts create mode 100644 apps/runner/src/g1-real-local.ts create mode 100644 apps/runner/src/g1-scheduling-harness.ts create mode 100644 apps/runner/src/g1-worker-rehearsal.ts create mode 100644 apps/runner/test/g1-artifacts-sidecar.test.ts create mode 100644 apps/runner/test/g1-bwrap-commands.test.ts create mode 100644 apps/runner/test/g1-local-preview.test.ts create mode 100644 apps/runner/test/g1-local-verifier.test.ts create mode 100644 apps/runner/test/g1-real-local.test.ts create mode 100644 apps/web/e2e/native.spec.ts create mode 100644 apps/web/playwright.native.config.ts create mode 100644 apps/web/src/api/native.ts create mode 100644 apps/web/src/screens/NativeReview.tsx create mode 100644 apps/web/src/screens/ProjectKnowledge.tsx create mode 100644 apps/web/src/screens/ProjectOutcomes.tsx create mode 100644 apps/web/test/native.test.tsx create mode 100644 apps/worker/e2e/real-preview.ts create mode 100644 apps/worker/e2e/real-stack.ts create mode 100644 apps/worker/migrations/0019_native_release_bindings.sql create mode 100644 apps/worker/src/local-artifacts.ts create mode 100644 apps/worker/src/routes/native-payloads.ts create mode 100644 apps/worker/src/routes/native-projects.ts create mode 100644 apps/worker/src/routes/native-submit.ts create mode 100644 apps/worker/src/services/native-payloads.ts create mode 100644 apps/worker/src/services/native-release.ts create mode 100644 apps/worker/test/api/native-payloads.test.ts create mode 100644 apps/worker/test/api/native-projects.test.ts create mode 100644 apps/worker/test/api/native-release.test.ts create mode 100644 apps/worker/test/fault/native-acceptance.fault.test.ts create mode 100644 apps/worker/test/security/native-projects.test.ts create mode 100644 apps/worker/test/security/native-submit.test.ts create mode 100644 apps/worker/test/security/native-verification.test.ts create mode 100644 docs/agent-native/README.md create mode 100644 docs/agent-native/agency-consumer.md create mode 100644 docs/agent-native/architecture.md create mode 100644 docs/agent-native/authority-map.md create mode 100644 docs/agent-native/evidence/fresh-agent-implementation.diff create mode 100644 docs/agent-native/evidence/rehearsals.json create mode 100644 docs/agent-native/execution-status.md create mode 100644 docs/agent-native/images/knowledge.png create mode 100644 docs/agent-native/images/outcomes.png create mode 100644 docs/agent-native/images/preview.png create mode 100644 docs/agent-native/images/review.png create mode 100644 docs/agent-native/images/source-accepted.png create mode 100644 docs/agent-native/images/source-review.png create mode 100644 docs/agent-native/implementation-plan.md create mode 100644 docs/agent-native/local-workflow.md create mode 100644 docs/agent-native/release-rehearsal.md create mode 100644 docs/agent-native/sol-6.1-goal.md create mode 100644 fixtures/scheduling/README.md create mode 100644 fixtures/scheduling/build.mjs create mode 100644 fixtures/scheduling/worker.mjs create mode 100644 packages/core/src/project.test.ts create mode 100644 packages/core/src/project.ts create mode 100644 packages/ledger/src/project-bundle.test.ts create mode 100644 packages/ledger/src/project-bundle.ts create mode 100644 packages/ledger/src/project.test.ts create mode 100644 packages/ledger/src/project.ts create mode 100644 packages/runner-protocol/src/project-scenarios.ts create mode 100644 packages/runner-protocol/test/project-scenarios.test.ts create mode 100755 scripts/dev-real-local.sh create mode 100755 scripts/dev-real-worker.sh create mode 100755 scripts/test-g1-local.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bd8e2c..3b79487 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: with: node-version: 22 cache: pnpm + - run: sudo apt-get update && sudo apt-get install -y bubblewrap + - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm build @@ -68,6 +70,33 @@ jobs: apps/web/test-results/ retention-days: 7 + g1-local: + name: G1 real Git, runner and owner browser journey + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v6.1.0 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + - run: sudo apt-get update && sudo apt-get install -y bubblewrap + # Ubuntu's AppArmor default may deny unprivileged namespaces on ephemeral CI hosts. + - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @commonforge-org/web exec playwright install --with-deps chromium + - run: pnpm test:g1:local + - if: always() + uses: actions/upload-artifact@v4 + with: + name: g1-sanitized-results + path: | + .g1-artifacts/local-ci/report.json + apps/web/test-results/native/**/*.png + apps/web/test-results/native/**/source-acceptance.json + retention-days: 7 + # Secrets are not readable in a job-level `if`, so detect them in a tiny job first. # Fork PRs never receive secrets, so the live job is skipped there. live-gate: diff --git a/.gitignore b/.gitignore index 499d6ab..158177e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,11 @@ session.json # run outputs of the live harness and demo scripts (commit a run deliberately with `git add -f`) evidence/ + +# Task-local verification artifacts (may contain local coordinates) +.g1-artifacts/ + +# Reviewed, sanitized G1 evidence summaries are deliberately public; raw run evidence stays ignored. +!/docs/agent-native/evidence/ +!/docs/agent-native/evidence/*.json +!/docs/agent-native/evidence/*.diff diff --git a/README.md b/README.md index 528917e..44bf805 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

-

Your clients build. Their agents help. You stay in control.

+

Your project’s understanding survives the agent.

CI @@ -14,10 +14,18 @@ Runs on Cloudflare Workers and Artifacts

-CommonForge is a self-hosted workspace where an agency, its clients and their coding agents change -a client's website together. Clients ask for changes as tasks. Agents work on each task in an -isolated copy of the code. Every change is checked, previewed on a staging site and approved -before it reaches production, and every accepted version can be restored. +CommonForge is a public, self-hosted platform for people and coding agents to own software +and its project knowledge. The reference product now includes confirmed rules, behavioral scenarios, +pinned task context, independently verified changes, and durable correction history. The existing +agency workflow remains available while a separate private agency consumer is developed. + +The **G1 implementation is under validation**. Run `pnpm dev:real-local` for the real Git/Worker/Node +stack, or `pnpm test:g1:local` for its bounded API and browser rehearsal. Knowledge, Outcomes and +Review are available to owners/developers; enabling project understanding is currently explicit. +See [execution status](docs/agent-native/execution-status.md) for measured gates and +[the local workflow](docs/agent-native/local-workflow.md) for setup and limits. Production native +release and complete Git bundle restoration have additional requirements; existing agency +production behavior is documented below. For developers: it runs entirely on **Cloudflare Workers** and **Cloudflare Artifacts**. You deploy it into your own Cloudflare account with one script, and agents work through a small CLI, diff --git a/apps/cli/src/checkout.ts b/apps/cli/src/checkout.ts index 21d3b08..5663a0f 100644 --- a/apps/cli/src/checkout.ts +++ b/apps/cli/src/checkout.ts @@ -12,7 +12,7 @@ * No token is ever written to disk, to git config, to workspace.json or to the output. */ import { mkdir, readdir, realpath, rm, stat } from "node:fs/promises"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { MaterializationPlanSchema, type MaterializationPlan } from "@commonforge-org/core"; import { materialize, validatePlan, verifyMaterialization, type AssetObjectRequest } from "@commonforge-org/runner-protocol"; import { createNodeIo } from "@commonforge-org/runner-protocol/node"; @@ -21,6 +21,7 @@ import { int, parse, positional, type CommandContext } from "./command.js"; import { CliError, scrub } from "./context.js"; import type { CheckoutRecord } from "./config.js"; import { gitEnv, helperConfigEntries, helperConfigEnv, installHelperConfig, lfsConfigEntries } from "./git.js"; +import { fetchNativeContext, writeNativeContext, type NativeContext } from "./native.js"; import { acquireLease, leaseTtl } from "./project.js"; const USAGE = "cforge checkout [--lease-ttl ] [--wait ] [--json]"; @@ -80,9 +81,10 @@ export async function checkout(ctx: CommandContext, args: readonly string[]): Pr let userApi: ApiClient | null = null; let leaseInfo: Record; let leaseToken: string; - const envLease = await leaseBearer(ctx.io, ctx.store, taskId, apiUrl).then((b) => (b?.source === "env" ? b : null)); + const nativeStored = await stat(join(ctx.store.dir, "native", `${taskId}.json`)).then(() => true, () => false); + const envLease = await leaseBearer(ctx.io, ctx.store, taskId, apiUrl).then(b => b?.source === "env" || nativeStored ? b : null); if (envLease) { - leaseInfo = { source: "env" }; + leaseInfo = { source: envLease.source }; leaseToken = envLease.token; } else { userApi = (await sessionClient(ctx.io, ctx.store, apiUrl)).api; @@ -115,7 +117,10 @@ export async function checkout(ctx: CommandContext, args: readonly string[]): Pr } if (plan.workspaceRoot !== root) throw new CliError("invalid_plan", `the plan is for ${plan.workspaceRoot}, not ${root}`); checkRemotes(plan); - await materializeAndVerify(ctx, plan, { apiUrl, taskId, root, leaseApi, leaseInfo, setRecord: (r) => void (recordPath = r) }); + const task = await leaseApi.get<{ projectProtocolVersion?: number }>(`/api/tasks/${encodeURIComponent(taskId)}`); + if (nativeStored && task.projectProtocolVersion !== 1 || task.projectProtocolVersion !== undefined && task.projectProtocolVersion !== 1) throw new CliError("unsupported_version", "unsupported project protocol"); + const nativeContext = task.projectProtocolVersion === 1 ? await fetchNativeContext(leaseApi, taskId) : null; + await materializeAndVerify(ctx, plan, { apiUrl, taskId, root, leaseApi, leaseInfo, setRecord: (r) => void (recordPath = r), ...(nativeContext ? { nativeContext } : {}) }); } catch (e) { await cleanup(); throw e; @@ -125,7 +130,7 @@ export async function checkout(ctx: CommandContext, args: readonly string[]): Pr async function materializeAndVerify( ctx: CommandContext, plan: MaterializationPlan, - o: { apiUrl: string; taskId: string; root: string; leaseApi: ApiClient; leaseInfo: Record; setRecord: (path: string) => void }, + o: { apiUrl: string; taskId: string; root: string; leaseApi: ApiClient; leaseInfo: Record; nativeContext?: NativeContext; setRecord: (path: string) => void }, ): Promise { const { apiUrl, taskId, root } = o; // The lease goes to an LFS URL only on the API's own origin (Tokens, folders phase 4). @@ -213,7 +218,8 @@ async function materializeAndVerify( ...(m.taskWorkspaceId ? { taskWorkspaceId: m.taskWorkspaceId } : {}), ...(m.source.kind === "git" ? { kind: "git", sha: m.source.sha, remoteUrl: m.source.remoteUrl } : { kind: "assets", manifestId: m.source.manifest.manifestId }), })); - ctx.out.result({ taskId, workspaceRoot: root, mounts, lease: o.leaseInfo, verified: true }, () => + if (o.nativeContext) await writeNativeContext(root, o.nativeContext); + ctx.out.result({ ...(o.nativeContext ? { projectProtocolVersion: 1, attemptId: o.nativeContext.attemptId, contextDigest: o.nativeContext.context.digest } : {}), taskId, workspaceRoot: root, mounts, lease: o.leaseInfo, verified: true }, () => [ `Checked out ${taskId} into ${root} (verified).`, ...mounts.map((m) => ` ${m.mountName.padEnd(20)} ${m.writable ? "read-write" : "read-only "} ${"sha" in m ? m.sha : m.manifestId}`), diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 62194bb..3bf8e68 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -2,6 +2,7 @@ import { createInterface as createLineReader } from "node:readline"; import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; +import { handoff, contextCommand, nativeCommand, propose, nativeResults } from "./native.js"; import { checkout } from "./checkout.js"; import { checkpoint } from "./checkpoint.js"; import type { CommandContext } from "./command.js"; @@ -26,6 +27,11 @@ Usage: cforge [options] login --email | --code sign in (CLI code flow); the session is stored 0600 in the config dir logout | whoami + handoff --code exchange one-time task authority (no broad user session) + results inspect this attempt’s proposal, evidence and decision history + propose integrate submitted candidates, then freeze exact project review + context [--out ] inspect and export pinned project understanding + native --command propose knowledge or an exact project change project list | show | tasks task create --title [--request ] [--write ...] | lease | release checkout materialize a task workspace (verified); git uses the credential helper @@ -51,6 +57,11 @@ const COMMANDS: Record = { project, task, checkout, + handoff, + context: contextCommand, + native: nativeCommand, + propose, + results: nativeResults, status, checkpoint, submit, diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 753c6d1..4522996 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -18,7 +18,8 @@ export type InvokeCli = (argv: string[]) => Promise<{ code: number; stdout: stri export const MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] as const; const INSTRUCTIONS = `CommonForge tools for working on a website or app project an agency manages. -Start with sign_in if a tool answers login_required: the person gets a one-time code from the CommonForge app (Account page, or the task's Give to agent). +For agent-native projects start with exchange_task_handoff and inspect_project_context; preserve the confirmed correction and submit only within the mandate. Never use a broad user login to work around denied agent authority. +For legacy projects start with sign_in if a tool answers login_required: the person gets a one-time code from the CommonForge app (Account page, or the task's Give to agent). To ask for a change: list_projects, then create_task with a title and the request in plain words. The project's owner decides which folders a task can change. To do the work (needs local files and git): checkout_task into an empty folder, edit only inside it, then submit_task. save_progress keeps work safe without handing it in.`; @@ -78,6 +79,41 @@ function absolute(path: string | undefined, what: string): string { } export const TOOLS: ToolDef[] = [ + { + name: "inspect_attempt_results", title: "Inspect own attempt results", + description: "Read this task-scoped attempt's exact proposals, evidence, human decisions and accepted manifests. No access to other task attempts.", + inputSchema: { type: "object", properties: { task_id: text("Assigned task id.") }, required: ["task_id"], additionalProperties: false }, + annotations: { readOnlyHint: true, openWorldHint: true }, + argv: a => ({ command: "results", args: [a.task_id ?? ""] }), + }, + { + name: "prepare_project_proposal", title: "Prepare exact project review", + description: "Integrate this task's submitted candidates. If queued, invoke again after completion to freeze the exact project envelope with current knowledge, source and runtime subjects. Does not approve or accept.", + inputSchema: { type: "object", properties: { task_id: text("Assigned task id."), inner_proposal_id: text("Optional exact integrated proposal id already returned by this task.") }, required: ["task_id"], additionalProperties: false }, + annotations: { idempotentHint: true, openWorldHint: true }, + argv: a => ({ command: "propose", options: a.inner_proposal_id ? [opt("inner-proposal", a.inner_proposal_id)] : [], args: [a.task_id ?? ""] }), + }, + { + name: "exchange_task_handoff", title: "Use task-scoped authority", + description: "Exchange a one-time project handoff code for only this task. Does not create a broad user session.", + inputSchema: { type: "object", properties: { code: text("The org.project.cfh_ task handoff code."), api_url: text("The CommonForge server origin.") }, required: ["code"], additionalProperties: false }, + annotations: { idempotentHint: false, openWorldHint: true }, + argv: a => ({ command: "handoff", options: [opt("code", a.code ?? ""), ...(a.api_url ? [opt("api-url", a.api_url)] : [])] }), + }, + { + name: "inspect_project_context", title: "Inspect pinned project understanding", + description: "Read exact confirmed rules, scenarios, assumptions and attempt authority; export protected JSON and Markdown context. Refuses stale knowledge.", + inputSchema: { type: "object", properties: { task_id: text("The assigned task id."), folder: text("Absolute workspace directory to receive .commonforge/context files.") }, required: ["task_id", "folder"], additionalProperties: false }, + annotations: { idempotentHint: true, openWorldHint: true }, + argv: a => ({ command: "context", options: [opt("out", absolute(a.folder, "folder"))], args: [a.task_id ?? ""] }), + }, + { + name: "propose_project_record", title: "Propose knowledge or an exact change", + description: "Submit a structured draftKnowledge, draftScenario, or prepareProposal command with expectedGeneration and idempotencyKey. Agent scope cannot confirm, approve, accept or deploy.", + inputSchema: { type: "object", properties: { task_id: text("The assigned task id."), command: text("Strict JSON project command including record, expectedGeneration and idempotencyKey.") }, required: ["task_id", "command"], additionalProperties: false }, + annotations: { idempotentHint: false, openWorldHint: true }, + argv: a => ({ command: "native", options: [opt("command", a.command ?? "")], args: [a.task_id ?? ""] }), + }, { name: "sign_in", title: "Sign in to CommonForge", @@ -264,7 +300,7 @@ export class McpServer { } catch (e) { return fail(e instanceof CliError ? e.code : "invalid_arguments", (e as Error).message); } - if (tool.name === "sign_in") { + if (tool.name === "sign_in" || tool.name === "exchange_task_handoff") { const refused = await this.#refuseSwitch(cmd); if (refused) return fail("signed_in_elsewhere", refused); } diff --git a/apps/cli/src/native.ts b/apps/cli/src/native.ts new file mode 100644 index 0000000..014ada8 --- /dev/null +++ b/apps/cli/src/native.ts @@ -0,0 +1,118 @@ +/** Task-scoped G1 handoff and immutable pinned context; no broad login is created. */ +import { chmod, lstat, mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { z } from 'zod'; +import { canonicalJson, materializeProjectContext, type ProjectContextPacket } from '@commonforge-org/core'; +import { ApiClient, agentClient, resolveApiUrl } from './api.js'; +import { parse, positional, str, type CommandContext } from './command.js'; +import { CliError, rememberSecret } from './context.js'; + +const SafeTaskId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/); +const ContextWire = z.object({ packet: z.unknown(), digest: z.string().regex(/^[a-f0-9]{64}$/), json: z.string(), markdown: z.string() }).strict(); +const Reply = z.object({ protocolVersion: z.literal(1), attemptId: z.string(), mandateId: z.string(), context: ContextWire, knowledgeStale: z.boolean().optional(), generation: z.number().int().nonnegative().optional(), agentId: z.string().optional() }); +export type NativeContext = { attemptId: string; mandateId: string; context: ReturnType; knowledgeStale?: boolean; generation?: number; agentId?: string }; +export function validateNativeContext(raw: unknown, taskId: string): NativeContext { + if (!SafeTaskId.safeParse(taskId).success) throw new CliError('context_invalid', 'invalid task identifier'); + const r = Reply.safeParse(raw); + if (!r.success) throw new CliError('unsupported_version', 'server returned unsupported project context'); + const materialized = materializeProjectContext(r.data.context.packet as ProjectContextPacket); + if (materialized.packet.taskId !== taskId || materialized.packet.attemptId !== r.data.attemptId || materialized.packet.mandate.id !== r.data.mandateId || materialized.digest !== r.data.context.digest || materialized.json !== r.data.context.json || materialized.markdown !== r.data.context.markdown) throw new CliError('context_invalid', 'project context digest or subject does not match'); + return { attemptId: r.data.attemptId, mandateId: r.data.mandateId, context: materialized, ...(r.data.generation === undefined ? {} : { generation: r.data.generation }), ...(r.data.agentId === undefined ? {} : { agentId: r.data.agentId }), ...(r.data.knowledgeStale === undefined ? {} : { knowledgeStale: r.data.knowledgeStale }) }; +} +async function protectedDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }); + if ((await lstat(path)).isSymbolicLink() || !(await lstat(path)).isDirectory()) throw new CliError('context_path_invalid', 'context destination must be a real directory'); + await chmod(path, 0o700); +} +async function safeWrite(dir: string, name: string, value: string): Promise { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,220}$/.test(name)) throw new CliError('context_path_invalid', 'invalid context filename'); + await protectedDirectory(dir); + const temp = join(dir, `.${name}.${crypto.randomUUID()}.tmp`); + await writeFile(temp, value, { mode: 0o600, flag: 'wx' }); + await rename(temp, join(dir, name)); +} +export async function writeNativeContext(root: string, data: NativeContext): Promise { + const dir = join(await realpath(root), '.commonforge'); + await safeWrite(dir, 'context.json', data.context.json + '\n'); + await safeWrite(dir, 'context.md', data.context.markdown); + await safeWrite(dir, 'attempt.json', canonicalJson({ protocolVersion: 1, taskId: data.context.packet.taskId, attemptId: data.attemptId, mandateId: data.mandateId, contextDigest: data.context.digest }) + '\n'); +} +export async function fetchNativeContext(api: ApiClient, taskId: string): Promise { + const data = validateNativeContext(await api.get(`/api/tasks/${encodeURIComponent(taskId)}/native/context`), taskId); + if (data.knowledgeStale) throw new CliError('knowledge_stale', 'project requirements changed; obtain a new task handoff and rerun verification'); + return data; +} +export async function checkPinnedNativeContext(root: string, data: NativeContext): Promise { + let pin: unknown; + try { pin = JSON.parse(await readFile(join(root, '.commonforge', 'attempt.json'), 'utf8')); } catch { throw new CliError('context_missing', 'native workspace has no pinned attempt; check out the task again'); } + const expected = { protocolVersion: 1, taskId: data.context.packet.taskId, attemptId: data.attemptId, mandateId: data.mandateId, contextDigest: data.context.digest }; + if (canonicalJson(pin) !== canonicalJson(expected)) throw new CliError('context_invalid', 'workspace attempt does not match current scoped authority'); + const raw = JSON.parse(await readFile(join(root, '.commonforge', 'context.json'), 'utf8')) as unknown; + if (materializeProjectContext(raw as ProjectContextPacket).digest !== data.context.digest) throw new CliError('context_invalid', 'workspace context was altered'); +} +export async function handoff(ctx: CommandContext, args: readonly string[]): Promise { + const p = parse(args, { code: { type: 'string' } }, 'cforge handoff --code '); + const code = str(p, 'code'); if (!code) throw new CliError('invalid_arguments', 'a task-scoped --code is required'); + rememberSecret(code); + const apiUrl = await resolveApiUrl(ctx.io, ctx.store, ctx.global.apiUrl); + const raw = await new ApiClient(ctx.io, apiUrl, null).post>('/api/auth/task/verify', { code }); + const lease = z.object({ taskId: SafeTaskId, leaseToken: z.string().startsWith('cfl_'), leaseGeneration: z.number().int().nonnegative(), expiresAt: z.string().datetime({ offset: true }) }).parse(raw); + rememberSecret(lease.leaseToken); + const data = validateNativeContext(raw, lease.taskId); + await ctx.store.saveConfig({ apiUrl }); + await ctx.store.saveLease({ apiUrl, ...lease }); + await safeWrite(join(ctx.store.dir, 'native'), `${lease.taskId}.json`, canonicalJson(data)); + ctx.out.result({ taskId: lease.taskId, attemptId: data.attemptId, mandateId: data.mandateId, contextDigest: data.context.digest, expiresAt: lease.expiresAt }, `Task-scoped handoff ready for ${lease.taskId}. Run cforge checkout, then read .commonforge/context.md.`); +} +export async function contextCommand(ctx: CommandContext, args: readonly string[]): Promise { + const p = parse(args, { out: { type: 'string' } }, 'cforge context [--out ]'); + const taskId = positional(p, 0, 'taskId', 'cforge context '); + const apiUrl = await resolveApiUrl(ctx.io, ctx.store, ctx.global.apiUrl); + const api = await agentClient(ctx.io, ctx.store, taskId, apiUrl); + const data = await fetchNativeContext(api, taskId); + const root = resolve(ctx.io.cwd, str(p, 'out') ?? '.'); + await mkdir(root, { recursive: true }); + await writeNativeContext(root, data); + ctx.out.result({ taskId, attemptId: data.attemptId, mandateId: data.mandateId, contextDigest: data.context.digest, packet: data.context.packet, generation: data.generation, agentId: data.agentId, directory: join(root, '.commonforge') }, `Pinned project context written to ${join(root, '.commonforge')}.`); +} +/** Structured agent correction/proposal command. Server refuses authority beyond mandate. */ +export async function nativeCommand(ctx: CommandContext, args: readonly string[]): Promise { + const p = parse(args, { command: { type: 'string' } }, 'cforge native --command '); + const taskId = positional(p, 0, 'taskId', 'cforge native --command '); + let body: unknown; try { body = JSON.parse(str(p, 'command') ?? ''); } catch { throw new CliError('invalid_arguments', '--command must be structured JSON'); } + const apiUrl = await resolveApiUrl(ctx.io, ctx.store, ctx.global.apiUrl); + const api = await agentClient(ctx.io, ctx.store, taskId, apiUrl); + const data = await fetchNativeContext(api, taskId); + if (body && typeof body === "object" && !Array.isArray(body)) body = { expectedGeneration: data.generation, idempotencyKey: crypto.randomUUID(), ...body }; + const result = await api.post(`/api/tasks/${encodeURIComponent(taskId)}/native/commands`, body); + ctx.out.result(result, 'Project command recorded. Drafts require separate human confirmation.'); +} + + +export async function propose(ctx: CommandContext, args: readonly string[]): Promise { + const p = parse(args, { 'inner-proposal': { type: 'string' } }, 'cforge propose [--inner-proposal ]'); + const taskId = positional(p, 0, 'taskId', 'cforge propose '); + const apiUrl = await resolveApiUrl(ctx.io, ctx.store, ctx.global.apiUrl); + const api = await agentClient(ctx.io, ctx.store, taskId, apiUrl); + let context = await fetchNativeContext(api, taskId); + let innerId = str(p, 'inner-proposal'); + if (!innerId) { + const inner = await api.post<{ innerProposalId: string; jobId: string; state: string }>(`/api/tasks/${encodeURIComponent(taskId)}/native/submit`, {}); + if (inner.state !== 'integrated') { ctx.out.result(inner, `Artifact integration queued (${inner.jobId}). Run cforge propose ${taskId} again after integration completes.`); return; } + innerId = inner.innerProposalId; + context = await fetchNativeContext(api, taskId); + } + if (context.generation === undefined) throw new CliError('unsupported_version', 'server must provide project generation for scoped proposal preparation'); + const result = await api.post(`/api/tasks/${encodeURIComponent(taskId)}/native/proposals`, { innerProposalId: innerId, expectedGeneration: context.generation, idempotencyKey: crypto.randomUUID(), outcome: null }); + ctx.out.result(result, 'Exact project proposal prepared. A human must request verification and review the evidence before accepting.'); +} + +export async function nativeResults(ctx: CommandContext, args: readonly string[]): Promise { + const p = parse(args, {}, 'cforge results '); + const taskId = positional(p, 0, 'taskId', 'cforge results '); + const apiUrl = await resolveApiUrl(ctx.io, ctx.store, ctx.global.apiUrl); + const api = await agentClient(ctx.io, ctx.store, taskId, apiUrl); + const result = await api.get>(`/api/tasks/${encodeURIComponent(taskId)}/native/results`); + if (result.protocolVersion !== 1) throw new CliError('unsupported_version', 'unsupported project results protocol'); + ctx.out.result(result, () => JSON.stringify(result, null, 2)); +} diff --git a/apps/cli/src/submit.ts b/apps/cli/src/submit.ts index feaac1b..a23a13f 100644 --- a/apps/cli/src/submit.ts +++ b/apps/cli/src/submit.ts @@ -4,12 +4,14 @@ * (`POST /api/tasks/:id/workspaces/:ws/freeze {expectedSha}`), which revokes the write tokens and * records the candidate. Every push happens before any freeze, so a failed push freezes nothing. */ -import { resolve } from "node:path"; -import { normalizeApiUrl, taskClient } from "./api.js"; +import { join, resolve } from "node:path"; +import { stat } from "node:fs/promises"; +import { agentClient, normalizeApiUrl, taskClient } from "./api.js"; import { list, parse, str, type CommandContext } from "./command.js"; import { CliError } from "./context.js"; import { commitAll } from "./checkpoint.js"; import { gitOk, pushOneRef, type GitRunner } from "./git.js"; +import { checkPinnedNativeContext, fetchNativeContext } from "./native.js"; import { pickWritableMounts, resolveWorkspace } from "./workspace.js"; const USAGE = "cforge submit [--mount ]... [--commit ] [--tool ] [--workspace ] [--json]"; @@ -40,6 +42,12 @@ export async function submit(ctx: CommandContext, args: readonly string[]): Prom const mounts = pickWritableMounts(ws, explicit); const taskId = ws.record.taskId; + const nativeStored = await stat(join(ctx.store.dir, "native", `${taskId}.json`)).then(() => true, () => false) || await stat(join(ws.root, ".commonforge", "attempt.json")).then(() => true, () => false); + const api = nativeStored ? await agentClient(ctx.io, ctx.store, taskId, normalizeApiUrl(ws.record.apiUrl)) : await taskClient(ctx.io, ctx.store, taskId, normalizeApiUrl(ws.record.apiUrl), (l) => ctx.out.warn(l)); + const task = await api.get<{ projectProtocolVersion?: number }>(`/api/tasks/${encodeURIComponent(taskId)}`); + if (nativeStored && task.projectProtocolVersion !== 1 || task.projectProtocolVersion !== undefined && task.projectProtocolVersion !== 1) throw new CliError("unsupported_version", "unsupported project protocol"); + if (task.projectProtocolVersion === 1) await checkPinnedNativeContext(ws.root, await fetchNativeContext(api, taskId)); + const pushed: Array<{ mountName: string; taskWorkspaceId: string; sha: string; ref: string; committed: boolean }> = []; const skipped: string[] = []; for (const m of mounts) { @@ -60,7 +68,6 @@ export async function submit(ctx: CommandContext, args: readonly string[]): Prom } if (pushed.length === 0) throw new CliError("nothing_to_submit", `no writable mount has commits beyond its base (${skipped.join(", ") || "none"}); commit first or pass --mount`); - const api = await taskClient(ctx.io, ctx.store, taskId, normalizeApiUrl(ws.record.apiUrl), (l) => ctx.out.warn(l)); const candidates: Array = []; const summary = () => candidates.map((c) => ({ mountName: c.mountName, candidateId: c.candidateId, taskWorkspaceId: c.taskWorkspaceId, sha: c.sha, changedPaths: c.changedPaths, state: c.state })); for (const x of pushed) { diff --git a/apps/cli/test/mcp.test.ts b/apps/cli/test/mcp.test.ts index 397b219..d3cab78 100644 --- a/apps/cli/test/mcp.test.ts +++ b/apps/cli/test/mcp.test.ts @@ -55,7 +55,7 @@ describe("protocol", () => { expect(replies.get(1).result).toMatchObject({ protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "commonforge", version: "0.1.0" } }); expect(replies.get(1).result.instructions).toContain("sign_in"); const tools = replies.get(2).result.tools; - expect(tools.map((t: { name: string }) => t.name)).toEqual(["sign_in", "who_am_i", "list_projects", "list_tasks", "create_task", "get_task", "checkout_task", "save_progress", "submit_task"]); + expect(tools.map((t: { name: string }) => t.name)).toEqual(["inspect_attempt_results", "prepare_project_proposal", "exchange_task_handoff", "inspect_project_context", "propose_project_record", "sign_in", "who_am_i", "list_projects", "list_tasks", "create_task", "get_task", "checkout_task", "save_progress", "submit_task"]); for (const t of tools) { expect(t.inputSchema.type).toBe("object"); expect(t).not.toHaveProperty("argv"); diff --git a/apps/cli/test/native.test.ts b/apps/cli/test/native.test.ts new file mode 100644 index 0000000..8ecce3d --- /dev/null +++ b/apps/cli/test/native.test.ts @@ -0,0 +1,78 @@ +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { materializeProjectContext, type ProjectContextPacket } from '@commonforge-org/core'; +import { checkPinnedNativeContext, validateNativeContext, writeNativeContext } from '../src/native.js'; +import { ConfigStore } from '../src/config.js'; +import { run } from '../src/main.js'; + +const T = '2026-10-17T12:00:00Z'; +const D = 'a'.repeat(64); +const packet: ProjectContextPacket = { domain: 'cf.project-context/1', schemaVersion: 1, organizationId: 'org', projectId: 'project', taskId: 'task-1', attemptId: 'attempt-1', mandate: { schemaVersion: 1, organizationId: 'org', projectId: 'project', id: 'mandate-1', author: 'owner', createdAt: T, revision: 1, taskId: 'task-1', agentId: 'agent', issuer: 'owner', operations: ['context:read'], resourceIds: [], issuedAt: T, expiresAt: '2026-10-17T13:00:00Z', revocationGeneration: 0, revokedAt: null, maxAttempts: 1, maxJobs: 5, timeoutSeconds: 60 }, knowledgeHead: D, knowledgeGeneration: 1, outcomes: [], rules: [], assumptions: [], scenarios: [], sourceHeads: [], omissions: [] }; +const wire = { protocolVersion: 1 as const, attemptId: 'attempt-1', mandateId: 'mandate-1', context: materializeProjectContext(packet) }; +describe('task scoped project context', () => { + it('refuses changed digest, task subject and unnegotiated protocol', () => { + expect(validateNativeContext(wire, 'task-1').attemptId).toBe('attempt-1'); + expect(() => validateNativeContext(wire, 'another-task')).toThrow('subject'); + expect(() => validateNativeContext(wire, '../outside')).toThrow('identifier'); + expect(() => validateNativeContext({ ...wire, protocolVersion: 2 }, 'task-1')).toThrow('unsupported'); + expect(() => validateNativeContext({ ...wire, context: { ...wire.context, digest: 'b'.repeat(64) } }, 'task-1')).toThrow('digest'); + }); + it('stores context without credentials in protected files and detects tampering', async () => { + const root = await mkdtemp(join(tmpdir(), 'cf-native-')); + try { + const data = validateNativeContext(wire, 'task-1'); + await writeNativeContext(root, data); + expect((await stat(join(root, '.commonforge'))).mode & 0o777).toBe(0o700); + expect((await stat(join(root, '.commonforge/context.json'))).mode & 0o777).toBe(0o600); + await expect(checkPinnedNativeContext(root, data)).resolves.toBeUndefined(); + expect(await readFile(join(root, '.commonforge/context.md'), 'utf8')).toContain('Project context'); + await expect(checkPinnedNativeContext(root, { ...data, attemptId: 'altered' })).rejects.toThrow('authority'); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it('refuses symbolic link context destinations', async () => { + const root = await mkdtemp(join(tmpdir(), 'cf-native-')); + const outside = await mkdtemp(join(tmpdir(), 'cf-outside-')); + try { + await symlink(outside, join(root, '.commonforge')); + await expect(writeNativeContext(root, validateNativeContext(wire, 'task-1'))).rejects.toThrow('real directory'); + } finally { await rm(root, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } + }); + it.each([false, true])('refuses unsafe native submission before Git, including copied workspace without global marker (%s)', async copied => { + const root = await mkdtemp(join(tmpdir(), 'cf-native-submit-')); + const workspace = join(root, 'workspace'); const config = join(root, 'config'); + const apiUrl = 'http://localhost:8787'; let gitCalls = 0; + try { + await mkdir(join(workspace, '.commonforge'), { recursive: true }); + await writeFile(join(workspace, '.commonforge/workspace.json'), JSON.stringify({ snapshotId: 's', projectId: 'project', organizationId: 'org', mounts: [] })); + const store = new ConfigStore({}, config); + await store.saveCheckout({ apiUrl, taskId: 'task-1', workspaceRoot: workspace, mounts: [{ mountName: 'source', remoteUrl: 'file:///synthetic', writable: true, credential: 'none', taskWorkspaceId: 'ws', baseSha: 'base' }], createdAt: T }); + await store.saveLease({ apiUrl, taskId: 'task-1', leaseToken: 'cfl_onlytask123456', leaseGeneration: 1, expiresAt: '2050-01-01T00:00:00Z' }); + await store.saveSession({ apiUrl, sessionToken: 'cfs_broaduser123456', expiresAt: '2050-01-01T00:00:00Z', user: { userId: 'owner', email: 'synthetic@example.test' } }); + if (copied) await writeFile(join(workspace, '.commonforge/attempt.json'), '{}'); + else { await mkdir(join(config, 'native')); await writeFile(join(config, 'native/task-1.json'), '{}'); } + let output = ''; + const code = await run(['submit', '--commit', 'Must not commit', '--json'], { cwd: workspace, env: { CFORGE_CONFIG_DIR: config, CFORGE_API_URL: apiUrl }, stdout: x => { output += x; }, stderr: () => {}, fetch: async (url, options) => { + expect((options?.headers as Record).authorization).toBe('Bearer cfl_onlytask123456'); + if (String(url).endsWith('/native/context')) return Response.json({ ...wire, protocolVersion: 1, knowledgeStale: true }); + if (String(url).endsWith('/tasks/task-1')) return Response.json(copied ? {} : { projectProtocolVersion: 1 }); + throw new Error('unexpected mutation'); + } }, { git: async () => { gitCalls++; return { code: 0, stdout: '', stderr: '' }; } }); + expect(code).toBe(1); expect(output).toContain(copied ? 'unsupported_version' : 'knowledge_stale'); expect(gitCalls).toBe(0); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it('exchanges only task authority and never writes a broad user session or outputs the code/token', async () => { + const root = await mkdtemp(join(tmpdir(), 'cf-native-')); + const code = 'org.project.cfh_singleuse123456'; + const token = 'cfl_taskonly123456'; + let stdout = ''; let stderr = ''; + try { + const exit = await run(['handoff', '--code', code, '--json'], { cwd: root, env: { CFORGE_CONFIG_DIR: root, CFORGE_API_URL: 'http://localhost:8787' }, stdout: x => { stdout += x; }, stderr: x => { stderr += x; }, fetch: async (url, options) => { expect(String(url)).toContain('/api/auth/task/verify'); expect(options?.body).toBe(JSON.stringify({ code })); return Response.json({ ...wire, taskId: 'task-1', leaseToken: token, leaseGeneration: 1, expiresAt: '2026-10-17T13:00:00Z' }); } }); + expect(exit, stdout + stderr).toBe(0); + expect(stdout + stderr).not.toContain(code); expect(stdout + stderr).not.toContain(token); + await expect(stat(join(root, 'session.json'))).rejects.toThrow(); + expect((await stat(join(root, 'leases/task-1.json'))).mode & 0o777).toBe(0o600); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/apps/runner/src/g1-accepted-health.ts b/apps/runner/src/g1-accepted-health.ts new file mode 100644 index 0000000..4436fe6 --- /dev/null +++ b/apps/runner/src/g1-accepted-health.ts @@ -0,0 +1,26 @@ +/** Bind supplemental health observation to the immutable accepted integration revision. */ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseRevisionId } from '@commonforge-org/core'; +import { inspectIsolatedHealth } from './g1-local-verifier.js'; +const resultPath = process.argv[2]!, result = JSON.parse(await readFile(resultPath,'utf8')); +const operator = JSON.parse(await readFile(process.argv[3]!,'utf8')); +const owner = JSON.parse(await readFile(process.argv[4]!,'utf8')); +const head = result.acceptedSourceHeads[0], revision = parseRevisionId(head.revisionId); +if (revision.kind !== 'art') throw new Error('expected real Artifacts-compatible Git source'); +const resourceResponse = await fetch(`${operator.workerUrl}/api/orgs/${result.input.organizationId}/resources/${head.resourceId}`, {headers:{authorization:`Bearer ${owner.ownerToken}`}}); +if(!resourceResponse.ok)throw new Error('accepted resource unavailable');const resource=await resourceResponse.json() as {coords:{namespace:string;repoName:string}}; +const response = await fetch(`${operator.sidecarUrl}/rpc`,{method:'POST',headers:{authorization:`Bearer ${operator.sidecarKey}`,'content-type':'application/json'},body:JSON.stringify({method:'readFile',args:[resource.coords.namespace,resource.coords.repoName,revision.sha,'fixtures/scheduling/worker.mjs']})}); +if(!response.ok)throw new Error('accepted integration source unavailable');const data=await response.json() as {result:{bytes:string}}; +const bytes=Buffer.from(data.result.bytes,'base64'),fileDigest=createHash('sha256').update(bytes).digest('hex'); +const root=await mkdtemp(join(tmpdir(),'g1-accepted-health-')); +try{ + await writeFile(join(root,'worker.mjs'),bytes,{mode:0o400}); + const health=await inspectIsolatedHealth(root) as {supportContact?:string;ruleVersion?:number}; + if(health.supportContact!=='support@example.test'||health.ruleVersion!==2||fileDigest!==result.implementationAfterDigest)throw new Error('accepted integration changed the observed fresh behavior'); + result.supplementalAcceptedIntegration={revisionId:head.revisionId,implementationPath:'fixtures/scheduling/worker.mjs',fileDigest,equalsCandidateFileDigest:true,observedHealth:health,execution:'bubblewrap-linux-http',network:'none',controlPlaneMounted:false}; + await writeFile(resultPath,JSON.stringify(result,null,2)); + process.stdout.write(`${JSON.stringify(result.supplementalAcceptedIntegration,null,2)}\n`); +}finally{await rm(root,{recursive:true,force:true});} diff --git a/apps/runner/src/g1-artifacts-sidecar.ts b/apps/runner/src/g1-artifacts-sidecar.ts new file mode 100644 index 0000000..e1974ed --- /dev/null +++ b/apps/runner/src/g1-artifacts-sidecar.ts @@ -0,0 +1,140 @@ +/** Local Artifacts transport compatibility over REAL LocalGitProvider bare repos and smart HTTP. */ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkPath, hashCanonicalSync, IdSegmentSchema, localRevisionId, parseRevisionId, type ResourceRef } from '@commonforge-org/core'; +import { LocalGitProvider } from '@commonforge-org/provider-local-git'; +import { ArtifactsProviderError, type ArtifactsCommit, type ArtifactsCreatedRepo, type ArtifactsRepoInfo, type ArtifactsTokenInfo, type ArtifactsTokenMinted, type ArtifactsTransport, type ArtifactsTreeItem } from '@commonforge-org/provider-cloudflare'; +import { createGit, RUNNER_IDENTITY_ENV } from './git.js'; +interface Repo { ns: string; name: string; resource: ResourceRef; info: ArtifactsRepoInfo; tokens: Array } +export class RealLocalArtifacts implements ArtifactsTransport { + readonly kind = 'rest' as const; + readonly repos = new Map(); + readonly git = createGit({ allowFile: true }); + baseUrl = ''; + constructor(readonly provider: LocalGitProvider, readonly stateDir: string) {} + private repo(ns: string, name: string) { const r = this.repos.get(`${ns}/${name}`); if (!r) throw new ArtifactsProviderError('not_found', 'local repository not found'); return r; } + private remote(r: Repo) { if (r.resource.coords.kind !== 'local-git') throw new Error('local coords required'); return r.resource.coords.remoteUrl; } + private path(r: Repo) { return fileURLToPath(this.remote(r)); } + private pendingPersist: Promise = Promise.resolve(); + private async persist() { + const serialized = JSON.stringify([...this.repos]); + const write = this.pendingPersist.then(async () => { + const temporary = join(this.stateDir, `registry-${randomBytes(8).toString('hex')}.tmp`); + await writeFile(temporary, serialized, { mode: 0o600 }); + await rename(temporary, join(this.stateDir, 'registry.json')); + }); + this.pendingPersist = write.catch(() => undefined); return write; + } + async load() { + try { for (const [key, repo] of JSON.parse(await readFile(join(this.stateDir, 'registry.json'), 'utf8')) as Array<[string, Repo]>) this.repos.set(key, repo); } + catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } + } + async createRepo(ns: string, name: string, opts: { description?: string; defaultBranch?: string } = {}): Promise { + IdSegmentSchema.parse(ns); IdSegmentSchema.parse(name); + if (this.repos.has(`${ns}/${name}`)) throw new ArtifactsProviderError('already_exists', 'local repository exists'); + const resource = await this.provider.createCanonicalResource({ organizationId: 'local-adapter', resourceId: `r${hashCanonicalSync({ ns, name }).slice(0, 40)}`, branch: opts.defaultBranch ?? 'main' }); + const info = { id: resource.resourceId, name, defaultBranch: opts.defaultBranch ?? 'main', remote: `${this.baseUrl}/git/${ns}/${name}.git`, source: null, readOnly: false }; + const repo: Repo = { ns, name, resource, info, tokens: [] }; this.repos.set(`${ns}/${name}`, repo); + const token = await this.createToken(ns, name, 'write', 3600); await this.persist(); + return { ...info, token: token.plaintext }; + } + async getRepo(ns: string, name: string) { return this.repos.get(`${ns}/${name}`)?.info ?? null; } + async deleteRepo(ns: string, name: string) { + const repo = this.repos.get(`${ns}/${name}`); if (!repo) return false; + // Task-owned state only. Provider registry retains historical identity; no reuse after delete. + await rm(this.path(repo), { recursive: true, force: true }); this.repos.delete(`${ns}/${name}`); await this.persist(); return true; + } + async createToken(ns: string, name: string, scope: 'read' | 'write', ttl: number): Promise { + if (!['read', 'write'].includes(scope) || !Number.isInteger(ttl) || ttl < 60 || ttl > 31536000) throw new Error('invalid token grant'); + const token = { id: `tok-${randomBytes(12).toString('hex')}`, plaintext: `art_v2_x_${randomBytes(32).toString('hex')}`, scope, expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), state: 'active' as const, createdAt: new Date().toISOString() }; + this.repo(ns, name).tokens.push(token); await this.persist(); return token; + } + async listTokens(ns: string, name: string) { return this.repo(ns, name).tokens.map(({ plaintext: _secret, ...t }) => ({ ...t, state: t.state === 'active' && Date.parse(t.expiresAt) <= Date.now() ? 'expired' as const : t.state })); } + async revokeToken(ns: string, name: string, id: string) { const t = this.repo(ns, name).tokens.find(t => t.id === id); if (!t) return false; t.state = 'revoked'; await this.persist(); return true; } + async log(ns: string, name: string, opts: { ref: string; limit?: number }): Promise { + if (opts.ref.startsWith('refs/') || opts.ref.startsWith('-') || /[\x00-\x20]/.test(opts.ref)) return []; + const r = this.repo(ns, name), found = await this.git.run(['rev-parse', '--verify', '--end-of-options', `${opts.ref}^{commit}`], this.path(r)); + if (found.code !== 0) return []; + const shas = (await this.git.ok(['rev-list', '--first-parent', `--max-count=${Math.min(opts.limit ?? 20, 1000)}`, found.stdout.trim()], this.path(r))).trim().split('\n').filter(Boolean); + return (await Promise.all(shas.map(s => this.readCommit(ns, name, s)))).filter((c): c is ArtifactsCommit => c !== null); + } + async readCommit(ns: string, name: string, sha: string): Promise { + if (!/^[a-f0-9]{40,64}$/.test(sha)) return null; + const r = await this.git.run(['show', '-s', '--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%cn%x00%ce%x00%at%x00%ct%x00%B', sha], this.path(this.repo(ns, name))); + if (r.code !== 0) return null; + const [hash, treeHash, parents, an, ae, cn, ce, at, ct, ...msg] = r.stdout.split('\0'); + return { hash: hash!, treeHash: treeHash!, parents: parents ? parents.split(' ') : [], author: { name: an!, email: ae! }, committer: { name: cn!, email: ce! }, authoredAt: Number(at), committedAt: Number(ct), message: msg.join('\0').trimEnd() }; + } + async readTree(ns: string, name: string, treeSha: string): Promise { + if (!/^[a-f0-9]{40,64}$/.test(treeSha)) return null; + const r = await this.git.run(['ls-tree', '-z', treeSha], this.path(this.repo(ns, name))); if (r.code !== 0) return null; + return r.stdout.split('\0').filter(Boolean).map(s => { const tab = s.indexOf('\t'), [mode, type, hash] = s.slice(0, tab).split(' '); return { name: s.slice(tab + 1), mode: mode === '040000' ? '40000' : mode!, hash: hash!, type: mode === '120000' ? 'symlink' : mode === '160000' ? 'gitlink' : mode === '100755' ? 'exec' : type as 'tree' | 'blob' }; }); + } + async readFile(ns: string, name: string, ref: string, path: string): Promise { + if (!checkPath(path).ok) return null; const [commit] = await this.log(ns, name, { ref, limit: 1 }); if (!commit) return null; + const repo = this.repo(ns, name); return this.provider.readFile(repo.resource, localRevisionId(repo.resource.resourceId, commit.hash), path); + } + async seedScheduling(ns: string, name: string) { + const repo = this.repo(ns, name), seed = join(this.stateDir, `seed-${randomBytes(8).toString('hex')}`); await mkdir(seed); + try { + for (const file of ['worker.mjs', 'build.mjs']) await copyFile(new URL(`../../../fixtures/scheduling/${file}`, import.meta.url), join(seed, file)); + await this.git.ok(['init', '-q', '-b', repo.info.defaultBranch], seed); await this.git.ok(['add', '.'], seed); + await this.git.ok(['commit', '-q', '-m', 'Synthetic scheduling fixture'], seed, { env: RUNNER_IDENTITY_ENV(new Date().toISOString()) }); + await this.git.ok(['push', '-q', this.remote(repo), `HEAD:refs/heads/${repo.info.defaultBranch}`], seed, { env: await this.provider.serviceGitEnv() }); + return { sha: (await this.git.ok(['rev-parse', 'HEAD'], seed)).trim() }; + } finally { await rm(seed, { recursive: true, force: true }); } + } + async candidateData(organizationId: string, resourceId: string, revisionId: string, namespacePrefix: string): Promise { + const revision = parseRevisionId(revisionId); + if (revision.kind !== 'art') throw new Error('expected Artifacts-compatible revision'); + const repo = [...this.repos.values()].find(r => r.info.id === revision.repoId && r.ns === `${namespacePrefix}-${organizationId}-canonical` && r.name === resourceId); + if (!repo) throw new Error('source scope mismatch'); + const revisionRef = localRevisionId(repo.resource.resourceId, revision.sha); + const data = await this.provider.readFile(repo.resource, revisionRef, 'worker.mjs') ?? await this.provider.readFile(repo.resource, revisionRef, 'fixtures/scheduling/worker.mjs'); + if (!data) throw new Error('missing candidate module'); return data; + } + async gitHttp(req: IncomingMessage, res: ServerResponse, url: URL) { + const match = /^\/git\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/.exec(url.pathname); + if (!match) { res.writeHead(404); res.end(); return; } + const repo = this.repo(match[1]!, match[2]!); + const secret = req.headers.authorization?.replace(/^Bearer /, '') ?? ''; + const token = repo.tokens.find(t => t.plaintext.length === secret.length && timingSafeEqual(Buffer.from(t.plaintext), Buffer.from(secret))); + const write = match[3] === 'git-receive-pack' || url.searchParams.get('service') === 'git-receive-pack'; + if (!token || token.state !== 'active' || Date.parse(token.expiresAt) <= Date.now() || (write && token.scope !== 'write')) { res.writeHead(403); res.end('repository access denied'); return; } + const chunks: Buffer[] = []; let count = 0; + for await (const chunk of req) { count += (chunk as Buffer).length; if (count > 33554432) { res.writeHead(413); res.end(); return; } chunks.push(chunk as Buffer); } + const body = Buffer.concat(chunks); + const child = spawn('git', ['http-backend'], { env: { PATH: process.env.PATH ?? '/usr/bin:/bin', GIT_CONFIG_NOSYSTEM: '1', HOME: '/nonexistent', GIT_PROJECT_ROOT: dirname(this.path(repo)), PATH_INFO: `/${basename(this.path(repo))}/${match[3]}`, QUERY_STRING: url.searchParams.toString(), REQUEST_METHOD: req.method ?? 'GET', CONTENT_TYPE: req.headers['content-type'] ?? '', CONTENT_LENGTH: String(body.length), REMOTE_USER: 'commonforge-local', GIT_HTTP_EXPORT_ALL: '1', COMMONFORGE_TOKEN: await this.provider.serviceToken() }, stdio: ['pipe', 'pipe', 'pipe'] }); + const out: Buffer[] = []; let bytes = 0; const timer = setTimeout(() => child.kill('SIGKILL'), 30000); + child.stdout.on('data', (b: Buffer) => { bytes += b.length; if (bytes > 67108864) child.kill('SIGKILL'); else out.push(b); }); child.stderr.resume(); + child.once('error', () => { clearTimeout(timer); if (!res.headersSent) { res.writeHead(503); res.end(); } }); + child.once('close', () => { clearTimeout(timer); if (res.headersSent) return; const result = Buffer.concat(out), split = result.indexOf('\r\n\r\n'); if (split < 0) { res.writeHead(503); res.end(); return; } const lines = result.subarray(0, split).toString().split('\r\n'), headers: Record = {}; let status = 200; for (const line of lines) { const i = line.indexOf(':'); if (i < 0) continue; const key = line.slice(0, i).toLowerCase(), value = line.slice(i + 1).trim(); if (key === 'status') status = Number(value.slice(0, 3)); else headers[key] = value; } res.writeHead(status, headers); res.end(result.subarray(split + 4)); }); + child.stdin.on('error', () => undefined); child.stdin.end(body); + } +} +export async function startArtifactsSidecar(options: { stateDir: string; key: string; port?: number }) { + const stateDir = resolve(options.stateDir); await mkdir(stateDir, { recursive: true, mode: 0o700 }); + const provider = await LocalGitProvider.open({ root: join(stateDir, 'repos'), namespacePrefix: 'commonforge-sidecar' }); + const artifacts = new RealLocalArtifacts(provider, stateDir); await artifacts.load(); + const server = createServer((req, res) => { void (async () => { + const url = new URL(req.url ?? '/', 'http://localhost'); + if (req.headers.origin) { res.writeHead(403); res.end(); return; } + if (url.pathname.startsWith('/git/')) return artifacts.gitHttp(req, res, url); + if (req.headers.authorization !== `Bearer ${options.key}` || req.method !== 'POST' || url.pathname !== '/rpc') { res.writeHead(403); res.end(); return; } + const chunks: Buffer[] = []; let count = 0; for await (const chunk of req) { count += (chunk as Buffer).length; if (count > 1048576) throw new Error('RPC too large'); chunks.push(chunk as Buffer); } + const { method, args } = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { method: string; args: unknown[] }; + const allowed = ['createRepo', 'getRepo', 'deleteRepo', 'createToken', 'listTokens', 'revokeToken', 'log', 'readCommit', 'readTree', 'readFile', 'seedScheduling']; + if (!allowed.includes(method) || !Array.isArray(args) || args.length > 4) throw new Error('unsupported RPC'); + const result = await (artifacts[method as keyof RealLocalArtifacts] as (...args: unknown[]) => Promise).apply(artifacts, args); + res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ result: result instanceof Uint8Array ? { bytes: Buffer.from(result).toString('base64') } : result })); + })().catch(e => { if (!res.headersSent) { res.writeHead(e instanceof ArtifactsProviderError ? 409 : 400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { code: e instanceof ArtifactsProviderError ? e.code : 'provider_unavailable', message: 'local source operation refused' } })); } }); }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', resolve); }); + artifacts.baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + // Rebind persisted remotes to this startup's actual local address. + for (const repo of artifacts.repos.values()) repo.info.remote = `${artifacts.baseUrl}/git/${repo.ns}/${repo.name}.git`; + return { provider, artifacts, url: artifacts.baseUrl, close: () => new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())) }; +} diff --git a/apps/runner/src/g1-bwrap-commands.ts b/apps/runner/src/g1-bwrap-commands.ts new file mode 100644 index 0000000..d534761 --- /dev/null +++ b/apps/runner/src/g1-bwrap-commands.ts @@ -0,0 +1,17 @@ +/** Narrow unprivileged Linux adapter: allow only Node entrypoints, all inside mount/PID/net namespaces. */ +import { chmod, mkdir, realpath, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { CommandSettings } from './context.js'; +export async function localNamespaceCommands(binDir: string): Promise { + await mkdir(binDir, { recursive: true, mode: 0o700 }); + const node = await realpath(process.execPath); + const shellQuote = (value: string) => `'${value.replace(/'/g, `'"'"'`)}'`; + const script = `#!/usr/bin/bash\nset -euo pipefail\nexec /usr/bin/bwrap --unshare-all --die-with-parent --new-session --cap-drop ALL --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 --ro-bind ${shellQuote(node)} /node --proc /proc --dev /dev --tmpfs /tmp --bind "$PWD" /candidate --chdir /candidate /node "$@"\n`; + await writeFile(join(binDir, 'node'), script, { mode: 0o700 }); await chmod(join(binDir, 'node'), 0o700); + return { + // Existing runner uid guard is bypassed only because this pinned wrapper supplies the stronger + // namespace boundary. Candidate code sees /candidate and system runtime files, never the supervisor. + unsafeSameUid: true, path: binDir, allowlist: [['node', '{path}', '{args*}']], + network: 'inherit', install: { enabled: false }, maxOutputBytes: 65536, + }; +} diff --git a/apps/runner/src/g1-candidate-host.mjs b/apps/runner/src/g1-candidate-host.mjs new file mode 100644 index 0000000..edde768 --- /dev/null +++ b/apps/runner/src/g1-candidate-host.mjs @@ -0,0 +1,15 @@ +// This file and candidate code execute together in a namespace with no control-plane mounts. +import { createServer } from 'node:http'; +import worker from '/candidate/worker.mjs'; +const server = createServer(async (req, res) => { + try { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const request = new Request('http://fixture' + req.url, { method: req.method, ...(req.method === 'POST' ? { body: Buffer.concat(chunks), headers: { 'content-type': 'application/json' } } : {}) }); + const response = await worker.fetch(request); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(Buffer.from(await response.arrayBuffer())); + } catch { res.writeHead(500); res.end('candidate exception'); } +}); +// Separate socket handed to the trusted supervisor; no host network exists inside the namespace. +server.listen('/scratch/preview.sock', () => process.stdout.write('READY\n')); diff --git a/apps/runner/src/g1-dev-worker.ts b/apps/runner/src/g1-dev-worker.ts new file mode 100644 index 0000000..a9597e0 --- /dev/null +++ b/apps/runner/src/g1-dev-worker.ts @@ -0,0 +1,85 @@ +/** Start a disposable real-local Worker + Node source sidecar + pull runner. */ +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startArtifactsSidecar } from './g1-artifacts-sidecar.js'; +import { startLocalScenarioBridge } from './g1-local-bridge.js'; +import { startLocalPreview } from './g1-local-preview.js'; +import { localNamespaceCommands } from './g1-bwrap-commands.js'; +import { brokerCredentials } from './credentials.js'; +import { createRunner } from './runner.js'; +import { createWorkerClient } from './worker-client.js'; +import { workerBlobStore } from './blob-store.js'; + +async function command(args: string[], cwd: string) { + await new Promise((resolve, reject) => { const child = spawn('pnpm', args, { cwd, stdio: ['ignore', process.stderr, process.stderr] }); child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`command exited ${code}`))); }); +} +async function main() { + const repo = fileURLToPath(new URL('../../../', import.meta.url)), workerDir = join(repo, 'apps/worker'); + const root = process.env.G1_STATE_DIR ? resolve(process.env.G1_STATE_DIR) : await mkdtemp(join(tmpdir(), 'commonforge-g1-real-worker-')); + const previous = await readFile(join(root, 'worker.json'), 'utf8').then(s => JSON.parse(s) as { vars: Record }, () => null); + const priorOperator = previous ? JSON.parse(await readFile(join(root, 'operator.json'), 'utf8')) as { sidecarUrl: string; sidecarKey: string; bridgeUrl: string; jobKey: {kid:string;secret:string} } : null; + const port = Number(process.env.PORT ?? 8899), previewPort = Number(process.env.PREVIEW_PORT ?? 8900); + if (!Number.isInteger(port) || port <= 1024 || port > 65535 || !Number.isInteger(previewPort) || previewPort <= 1024 || previewPort > 65535 || port === previewPort) throw new Error('PORT/PREVIEW_PORT must be distinct unprivileged ports'); + const workerUrl = `http://127.0.0.1:${port}`, previewUrl = `http://127.0.0.1:${previewPort}`; + for (const url of [workerUrl, previewUrl]) { + try { const r = await fetch(`${url}/health`, { signal: AbortSignal.timeout(500) }); await r.body?.cancel(); throw new Error(`port already occupied: ${url}`); } + catch (e) { if (e instanceof Error && e.message.startsWith('port already')) throw e; } + } + const random = () => randomBytes(32).toString('hex'), key = priorOperator?.sidecarKey ?? random(), runnerBearer = previous?.vars.COMMONFORGE_RUNNER_TOKEN ?? random(); + const jobKey = priorOperator?.jobKey ?? { kid: 'k1', secret: random() }, namespacePrefix = 'commonforge-real'; + const sidecar = await startArtifactsSidecar({ stateDir: join(root, 'source'), key, port: priorOperator ? Number(new URL(priorOperator.sidecarUrl).port) : undefined }); + const bridge = await startLocalScenarioBridge({ provider: sidecar.provider, jobKey, resources: [], port: priorOperator ? Number(new URL(priorOperator.bridgeUrl).port) : undefined, readCandidate: async request => { + const head = request.sourceHeads[0]!; return sidecar.artifacts.candidateData(request.organizationId, head.resourceId, head.revisionId, namespacePrefix); + } }); + const previewKey = previous?.vars.COMMONFORGE_LOCAL_PREVIEW_KEY ?? random(); + const localPreview = await startLocalPreview({ key: previewKey, port: previous?.vars.COMMONFORGE_LOCAL_PREVIEW_URL ? Number(new URL(previous.vars.COMMONFORGE_LOCAL_PREVIEW_URL).port) : undefined }); + const vars = { + COMMONFORGE_NAMESPACE_PREFIX: namespacePrefix, COMMONFORGE_ASSETS_BUCKET: 'commonforge-assets-real', + COMMONFORGE_APP_URL: workerUrl, COMMONFORGE_PUBLIC_URL: workerUrl, COMMONFORGE_EMAIL_FROM: 'login@example.test', COMMONFORGE_EMAIL_MODE: 'log', + COMMONFORGE_ADMIN_EMAILS: process.env.ADMIN_EMAIL ?? 'owner@agency.e2e.test', COMMONFORGE_DEPLOY_WORKER_PREFIX: 'cfapp', + COMMONFORGE_SESSION_SECRET: previous?.vars.COMMONFORGE_SESSION_SECRET ?? random(), COMMONFORGE_RUNNER_JOB_KEY: jobKey.secret, COMMONFORGE_RUNNER_TOKEN: runnerBearer, + COMMONFORGE_LOCAL_ARTIFACTS_URL: sidecar.url, COMMONFORGE_LOCAL_ARTIFACTS_KEY: key, COMMONFORGE_LOCAL_RUNNER_URL: bridge.url, COMMONFORGE_LOCAL_PREVIEW_URL: localPreview.url, COMMONFORGE_LOCAL_PREVIEW_KEY: previewKey, + COMMONFORGE_PREVIEW_URL: previewUrl, COMMONFORGE_PREVIEW_KEY: previous?.vars.COMMONFORGE_PREVIEW_KEY ?? random(), CF_ACCOUNT_ID: '00000000000000000000000000000000', CF_API_TOKEN: 'local-only-no-cloud-token', + }; + const bindings = { compatibility_date: '2026-10-01', compatibility_flags: ['nodejs_compat'], workers_dev: false, + r2_buckets: [{ binding: 'COMMONFORGE_ASSETS', bucket_name: 'commonforge-assets-real' }], vars }; + const config = { ...bindings, name: 'commonforge-real-local', main: join(workerDir, 'e2e/real-stack.ts'), + assets: { directory: join(repo, 'apps/web/dist'), not_found_handling: 'single-page-application', run_worker_first: ['/api/*', '/health'] }, + durable_objects: { bindings: [{ name: 'ORG_LEDGER', class_name: 'OrgLedger' }, { name: 'OPS_GATE', class_name: 'OpsGate' }] }, + migrations: [{ tag: 'v1', new_sqlite_classes: ['OrgLedger'] }, { tag: 'v2', new_sqlite_classes: ['OpsGate'] }], + d1_databases: [{ binding: 'DB', database_name: 'commonforge-real', database_id: '00000000-0000-4000-8000-0000000000e3', migrations_dir: join(workerDir, 'migrations') }], + queues: { producers: [{ binding: 'OUTBOX', queue: 'commonforge-real-outbox' }, { binding: 'STEPS', queue: 'commonforge-real-steps' }, { binding: 'RUNNER_JOBS', queue: 'commonforge-real-runner-jobs' }], consumers: [ { queue: 'commonforge-real-outbox', max_batch_size: 4, max_batch_timeout: 1 }, { queue: 'commonforge-real-steps', max_batch_size: 4, max_batch_timeout: 1 }, { queue: 'commonforge-real-runner-jobs', max_batch_size: 1, max_batch_timeout: 1 }] }, + }; + const configPath = join(root, 'worker.json'), previewConfig = join(root, 'preview.json'), persist = join(root, 'miniflare'); + await writeFile(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + await writeFile(previewConfig, JSON.stringify({ ...bindings, vars: { COMMONFORGE_PREVIEW_KEY: vars.COMMONFORGE_PREVIEW_KEY, COMMONFORGE_LOCAL_PREVIEW_URL: localPreview.url, COMMONFORGE_LOCAL_PREVIEW_KEY: previewKey }, name: 'commonforge-real-preview', main: join(workerDir, 'e2e/real-preview.ts') }, null, 2), { mode: 0o600 }); + await writeFile(join(root, 'operator.json'), JSON.stringify({ sidecarUrl: sidecar.url, sidecarKey: key, bridgeUrl: bridge.url, jobKey, workerUrl, namespacePrefix }), { mode: 0o600 }); + const baseArgs = ['--filter', '@commonforge-org/worker', 'exec', 'wrangler']; + await command([...baseArgs, 'd1', 'migrations', 'apply', 'commonforge-real', '--local', '-c', configPath, '--persist-to', persist, '--env-file', join(workerDir, 'e2e/no-secrets.env')], repo); + const start = (path: string, listen: number) => spawn('pnpm', [...baseArgs, 'dev', '-c', path, '--persist-to', persist, '--env-file', join(workerDir, 'e2e/no-secrets.env'), '--ip', '127.0.0.1', '--port', String(listen), '--show-interactive-dev-session=false'], { cwd: repo, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = start(configPath, port), preview = start(previewConfig, previewPort); + for (const c of [child, preview]) { c.stdout.pipe(process.stderr); c.stderr.pipe(process.stderr); } + let stopped = false; + const stop = async () => { if (stopped) return; stopped = true; child.kill('SIGTERM'); preview.kill('SIGTERM'); await bridge.close(); await localPreview.close(); await sidecar.close(); }; + process.once('SIGINT', () => { void stop(); }); process.once('SIGTERM', () => { void stop(); }); + child.once('exit', () => { void stop(); }); + const headers = { authorization: `Bearer ${runnerBearer}` }, client = createWorkerClient({ workerUrl, headers, attempts: 2 }); + const runner = createRunner({ workDir: join(root, 'runner'), journalDir: join(root, 'journal'), trustedWorkerOrigin: workerUrl, + credentials: brokerCredentials({ headers }), jobKeyring: [jobKey], git: { allowHttp: true }, + commands: await localNamespaceCommands(join(root, 'sandbox-bin')), evidenceKey: job => client.evidenceKey(job), currentHeads: job => client.heads(job), + blobStore: workerBlobStore({ workerUrl, headers }), previewBaseUrl: previewUrl, runnerId: 'real-local-pull-runner', log: line => process.stderr.write(`${line}\n`) }); + const report = { mode: 'real-local-worker', pid: process.pid, root, workerUrl, previewUrl, sourceUrl: sidecar.url, bridgeUrl: bridge.url, operatorFile: join(root, 'operator.json'), + real: ['Worker API', 'SQLite Durable Objects', 'D1', 'local R2', 'Git smart HTTP', 'Node pull runner', 'isolated scheduling scenarios', 'static and isolated API previews'], + limitations: ['Cloud release is unavailable.', 'Only Node build/check entrypoints are supported; no dependency installs.', 'API previews support standalone Node-compatible modules and fresh synthetic state per request; Workers-only runtime APIs require cloud verification.'] }; + await writeFile(join(root, 'report.json'), JSON.stringify(report, null, 2)); process.stdout.write(`${JSON.stringify(report)}\n`); + while (!stopped) { + try { const delivery = await client.nextJob('real-local-pull-runner'); if (delivery) { const result = await runner.execute(delivery); const job = runner.lastVerified.get(result.jobId); if (job) await client.postResult(job, result); } } + catch (e) { if (!stopped) process.stderr.write(`[real-local runner] ${e instanceof Error ? e.message : String(e)}\n`); } + if (!stopped) await new Promise(resolve => setTimeout(resolve, 250)); + } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) void main().catch(e => { process.stderr.write(`${e instanceof Error ? e.stack : String(e)}\n`); process.exit(1); }); diff --git a/apps/runner/src/g1-fresh-session.ts b/apps/runner/src/g1-fresh-session.ts new file mode 100644 index 0000000..130af05 --- /dev/null +++ b/apps/runner/src/g1-fresh-session.ts @@ -0,0 +1,35 @@ +/** Prepare scoped persisted inputs for a real, separately invoked agent. Does NOT simulate an agent. */ +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createGit } from './git.js'; +const session = JSON.parse(await readFile(process.argv[2]!, 'utf8')) as { ownerToken: string; organizationId: string; projectId: string }; +const operator = JSON.parse(await readFile(process.argv[3]!, 'utf8')) as { workerUrl: string }; +async function api(method: string, path: string, body?: unknown, token = session.ownerToken): Promise { + const res = await fetch(`${operator.workerUrl}/api${path}`, { method, headers: { authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + const data = await res.json(); if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${JSON.stringify(data)}`); return data; +} +const taskText = 'Add supportContact metadata to health response with value support@example.test; keep existing project behavior'; +const root = await mkdtemp(join(tmpdir(), 'g1-fresh-agent-')); +const task = await api('POST', `/orgs/${session.organizationId}/projects/${session.projectId}/tasks`, { title: 'Add support contact to fixture health', request: taskText, writeMounts: ['app'] }); +let ready: any; const end = Date.now() + 60000; +while (Date.now() < end) { ready = await api('GET', `/tasks/${task.taskId}`); if (ready.state === 'ready') break; await new Promise(r => setTimeout(r, 250)); } +if (ready.state !== 'ready') throw new Error('task source seeding timed out'); +const handoff = await api('POST', `/tasks/${task.taskId}/native/handoff`, { agentId: 'actual-fresh-session-codex' }); +const agent = await api('POST', '/auth/task/verify', { code: handoff.code }); +const context = await api('GET', `/tasks/${task.taskId}/native/context`, undefined, agent.leaseToken); +const credential = await api('POST', `/tasks/${task.taskId}/credential`, { mountName: 'app', access: 'write' }, agent.leaseToken); +const work = join(root, 'workspace'), git = createGit({ allowHttp: true }); +const env = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.${credential.remoteUrl}.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Bearer ${credential.secret}` }; +await git.ok(['clone', '-q', credential.remoteUrl, work], root, { env }); +await mkdir(join(work, '.commonforge'), { recursive: true }); +const inputs = { 'task.md': taskText + '\n', '.commonforge/context.json': context.context.json, '.commonforge/context.md': context.context.markdown }; +for (const [path, text] of Object.entries(inputs)) await writeFile(join(work, path), text); +const inputDigests = Object.fromEntries(Object.entries(inputs).map(([path, text]) => [path, createHash('sha256').update(text).digest('hex')])); +const sourceFile = 'fixtures/scheduling/worker.mjs'; +const sourceDigest = createHash('sha256').update(await readFile(join(work, sourceFile))).digest('hex'); +const report = { root, workspace: work, taskId: task.taskId, organizationId: session.organizationId, projectId: session.projectId, allowedInputFiles: Object.keys(inputs), allowedImplementationPath: sourceFile, inputDigests, implementationBeforeDigest: sourceDigest, contextDigest: context.context.digest, agentIdentity: null, freshAgentExecuted: false }; +await writeFile(join(root, 'agent-control.json'), JSON.stringify({ ...report, credential, agent, ownerSessionFile: process.argv[2], workerUrl: operator.workerUrl, workspaceId: ready.workspaces.find((w: any) => w.mountName === 'app').taskWorkspaceId }), { mode: 0o600 }); +await writeFile(join(root, 'report.json'), JSON.stringify(report, null, 2)); +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/apps/runner/src/g1-fresh-submit.ts b/apps/runner/src/g1-fresh-submit.ts new file mode 100644 index 0000000..f166a2e --- /dev/null +++ b/apps/runner/src/g1-fresh-submit.ts @@ -0,0 +1,43 @@ +/** Supervisor submission AFTER an actual external fresh agent edited its allowed source file. */ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { createGit, RUNNER_IDENTITY_ENV } from './git.js'; +import { inspectIsolatedHealth } from './g1-local-verifier.js'; +const controlFile = process.argv[2]!, control = JSON.parse(await readFile(controlFile,'utf8')); +const owner = JSON.parse(await readFile(control.ownerSessionFile,'utf8')); +const agentIdentity = process.argv[3]; +if (!agentIdentity || !/^\/[A-Za-z0-9_/-]+$/.test(agentIdentity)) throw new Error('record the actual independently invoked collaboration agent identity'); +async function api(method:string,path:string,body?:unknown,token=owner.ownerToken):Promise{ + const response=await fetch(`${control.workerUrl}/api${path}`,{method,headers:{authorization:`Bearer ${token}`,...(body===undefined?{}:{'content-type':'application/json'})},...(body===undefined?{}:{body:JSON.stringify(body)})}); + const data=await response.json();if(!response.ok)throw new Error(`${method} ${path}: ${response.status} ${JSON.stringify(data)}`);return data; +} +async function waitFor(path:string,ready:(x:any)=>boolean){const end=Date.now()+60000;while(Date.now()setTimeout(r,250));}throw new Error('real integration timed out');} +const git=createGit({allowHttp:true}),source=join(control.workspace,control.allowedImplementationPath); +for(const [path,digest]of Object.entries(control.inputDigests))if(createHash('sha256').update(await readFile(join(control.workspace,path))).digest('hex')!==digest)throw new Error('fresh agent altered pinned task/context inputs'); +const afterDigest=createHash('sha256').update(await readFile(source)).digest('hex');if(afterDigest===control.implementationBeforeDigest)throw new Error('fresh source was not changed'); +const changed=(await git.ok(['diff','--name-only'],control.workspace)).trim().split('\n').filter(Boolean);if(JSON.stringify(changed)!==JSON.stringify([control.allowedImplementationPath]))throw new Error('fresh agent changed source outside permitted file'); +const diff=await git.ok(['diff','--',control.allowedImplementationPath],control.workspace); +const health=await inspectIsolatedHealth(dirname(source)) as {supportContact?:string;ruleVersion?:number}; +if(health.supportContact!=='support@example.test'||health.ruleVersion!==2)throw new Error('fresh implementation health behavior did not match fixed task'); +await git.ok(['add','--',control.allowedImplementationPath],control.workspace);await git.ok(['commit','-q','-m','Fresh scoped agent adds support contact while preserving confirmed rules'],control.workspace,{env:RUNNER_IDENTITY_ENV(new Date().toISOString())}); +const sha=(await git.ok(['rev-parse','HEAD'],control.workspace)).trim(),cred=control.credential; +await git.ok(['push','-q','origin','HEAD:refs/heads/main'],control.workspace,{env:{GIT_CONFIG_COUNT:'1',GIT_CONFIG_KEY_0:`http.${cred.remoteUrl}.extraHeader`,GIT_CONFIG_VALUE_0:`Authorization: Bearer ${cred.secret}`}}); +const candidate=await api('POST',`/tasks/${control.taskId}/workspaces/${control.workspaceId}/freeze`,{expectedSha:sha,tool:'actual-fresh-codex-session'},control.agent.leaseToken); +const inner=await api('POST',`/orgs/${control.organizationId}/proposals`,{taskId:control.taskId,candidateIds:[candidate.candidateId]}); +const innerPath=`/orgs/${control.organizationId}/proposals/${inner.proposalId}`;await waitFor(innerPath,p=>p.state==='prepared');await api('POST',`${innerPath}/integrate`,{});const integrated=await waitFor(innerPath,p=>!!p.integration); +const native=`/orgs/${control.organizationId}/projects/${control.projectId}/native`; +let state=(await api('GET',native)).state; +const outer=await api('POST',`/tasks/${control.taskId}/native/proposals`,{innerProposalId:inner.proposalId,expectedGeneration:state.generation,idempotencyKey:`fresh-outer-${control.taskId}`},control.agent.leaseToken); +const verification=await api('POST',`${native}/proposals/${encodeURIComponent(outer.proposal.id)}/verify`,{idempotencyKey:`fresh-verify-${control.taskId}`});state=verification.state; +if(verification.evidence.length!==9||!verification.evidence.every((e:any)=>e.result==='pass'&&e.provenance==='runner-verified'))throw new Error('fresh source did not preserve every trusted scenario'); +const me=await api('GET','/me'),decisionId=`fresh-decision-${Date.now()}`; +const decision={schemaVersion:1,organizationId:control.organizationId,projectId:control.projectId,id:decisionId,author:me.user.userId,createdAt:new Date().toISOString(),envelopeDigest:outer.proposal.digest,innerProposalHash:outer.proposal.envelope.innerProposalHash,action:'approve',explanation:'Synthetic owner API decision after inspecting exact fresh source diff, independently executed scheduling evidence and isolated supportContact response.',authorityRevision:state.authorities.find((a:any)=>a.principalId===me.user.userId).revision}; +state=(await api('POST',`${native}/commands`,{type:'recordDecision',record:decision,expectedGeneration:state.generation,idempotencyKey:decisionId})).state; +state=(await api('POST',`${native}/commands`,{type:'accept',proposalId:outer.proposal.id,decisionId,expectedGeneration:state.generation,idempotencyKey:`accept-${decisionId}`})).state; +const report={mode:'actual-fresh-agent-real-local-api',agent:{canonicalIdentity:agentIdentity,facility:'Codex collaboration.spawn_agent',fork_turns:'none',model:'inherited default; independent model identifier not exposed by collaboration API',crossVendorClaim:false,originalConversationProvided:false,humanCorrectionRestated:false}, + input:JSON.parse(await readFile(join(control.root,'report.json'),'utf8')),implementationAfterDigest:afterDigest,sourceRevision:sha,proposalId:outer.proposal.id,envelopeDigest:outer.proposal.digest,acceptedManifest:state.manifests.at(-1).hash,acceptedSourceHeads:state.sourceHeads, + supplementalIsolatedHealth:health,scenarioEvidence:verification.evidence,artifactIntegration:integrated.integration,decision:{id:decisionId,actor:'synthetic owner role through API; not an observed human usability session'}, + gates:{freshAgentExecution:'pass',correctedRulePreserved:'pass',realGit:'pass',signedRunnerIntegration:'pass',independentScenarios:'pass',supplementalHealthBehavior:'pass',atomicAcceptance:'pass',cloudRelease:'not_run'}}; +await writeFile(join(control.root,'implementation.diff'),diff);await writeFile(join(control.root,'result.json'),JSON.stringify(report,null,2)); +process.stdout.write(`${JSON.stringify({reportFile:join(control.root,'result.json'),diffFile:join(control.root,'implementation.diff'),sourceRevision:sha,contextDigest:control.contextDigest,acceptedManifest:report.acceptedManifest,gates:report.gates},null,2)}\n`); diff --git a/apps/runner/src/g1-local-bridge.ts b/apps/runner/src/g1-local-bridge.ts new file mode 100644 index 0000000..8358541 --- /dev/null +++ b/apps/runner/src/g1-local-bridge.ts @@ -0,0 +1,77 @@ +/** Local-only, signed G1 verifier bridge over real LocalGitProvider revisions. */ +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { hashCanonicalSync, type RevisionId, type ScenarioEvidence } from '@commonforge-org/core'; +import { LocalGitProvider } from '@commonforge-org/provider-local-git'; +import { signScenarioResult, verifyScenarioRequest, type JobSigningKey } from '@commonforge-org/runner-protocol'; +import { schedulingVerifierDigest, verifySchedulingCandidate } from './g1-local-verifier.js'; +import { cases } from './g1-scheduling-harness.js'; + +export const SCHEDULING_RUNTIME_RECIPE_DIGEST = hashCanonicalSync({ domain: 'cf.local-scheduling-runtime/1', runtime: 'node-http', nodeVersion: process.versions.node, architecture: process.arch, isolation: 'bubblewrap-linux', clock: '2026-10-17T12:00:00.000Z', timezone: 'UTC' }); +export interface LocalBridgeOptions { + provider: LocalGitProvider; jobKey: JobSigningKey; port?: number; + /** Operator config, not caller-selected local paths. */ + readCandidate?: (request: import('@commonforge-org/runner-protocol').ScenarioVerificationRequest) => Promise; + resources: Array<{ organizationId: string; projectId: string; resourceId: string; workerPath?: string }>; +} +export async function startLocalScenarioBridge(options: LocalBridgeOptions): Promise<{ server: Server; url: string; close(): Promise }> { + const verifierDigest = await schedulingVerifierDigest(); + const runs = new Map }>(); + const server = createServer((req, res) => { + const send = (status: number, data: unknown) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)); }; + void (async () => { + if (req.headers.origin) return send(403, { error: 'browser requests refused' }); + if (req.method === 'GET' && (req.url === '/health' || req.url === '/capabilities')) return send(200, { + mode: 'real-local', protocolVersion: 1, verifierDigest, runtimeRecipeDigest: SCHEDULING_RUNTIME_RECIPE_DIGEST, + scenarioIds: cases.map(c => c[0]), isolation: 'bubblewrap-linux-http', cloudRelease: false, + }); + if (req.method !== 'POST' || req.url !== '/verify') return send(404, { error: 'not_found' }); + const chunks: Buffer[] = []; let bytes = 0; + for await (const chunk of req) { bytes += (chunk as Buffer).length; if (bytes > 262144) return send(413, { error: 'body_too_large' }); chunks.push(chunk as Buffer); } + const request = await verifyScenarioRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')), options.jobKey); + if (request.runtimeRecipeDigest !== SCHEDULING_RUNTIME_RECIPE_DIGEST || request.sourceHeads.length !== 1 || request.requiredScenarios.some(s => s.method !== 'automated' || s.verifierDigest !== verifierDigest || !cases.some(c => c[0] === s.id))) return send(422, { error: 'unsupported_pinned_verifier' }); + const head = request.sourceHeads[0]!; + const registration = options.resources.find(r => r.organizationId === request.organizationId && r.projectId === request.projectId && r.resourceId === head.resourceId); + if (!registration && !options.readCandidate) return send(403, { error: 'resource_scope_denied' }); + const digest = hashCanonicalSync(request); + const previous = runs.get(request.runId); + if (previous && previous.digest !== digest) return send(409, { error: 'run_id_conflict' }); + if (!previous && runs.size >= 1000) return send(503, { error: 'run_limit_reached_restart_or_rotate_bridge' }); + const execute = async () => { + const data = options.readCandidate ? await options.readCandidate(request) : await (async () => { + const resource = await options.provider.openCanonicalResource(registration!.organizationId, registration!.resourceId); + if (!resource) throw new Error('registered resource missing'); + return options.provider.readFile(resource, head.revisionId as RevisionId, registration!.workerPath ?? 'worker.mjs'); + })(); + if (!data || data.length > 1048576) throw new Error('candidate module absent or oversized'); + const candidate = await mkdtemp(join(tmpdir(), 'commonforge-g1-bridge-')); + const startedAt = new Date().toISOString(); + try { + await writeFile(join(candidate, 'worker.mjs'), data, { mode: 0o400 }); + const verified = await verifySchedulingCandidate(candidate); + const completedAt = new Date().toISOString(); + if (Date.parse(completedAt) > Date.parse(request.expiresAt)) throw new Error('verification expired'); + const evidence: ScenarioEvidence[] = request.requiredScenarios.map(s => { + const result = verified.results.find(r => r.scenarioId === s.id)!; + return { schemaVersion: 1, organizationId: request.organizationId, projectId: request.projectId, + id: `ev-${request.runId}-${s.id}`, author: 'local-node-runner', createdAt: completedAt, + envelopeDigest: request.envelopeDigest, buildDigest: request.buildDigest, runtimeRecipeDigest: request.runtimeRecipeDigest, + scenarioId: s.id, scenarioRevision: s.revision, verifierDigest: s.verifierDigest, method: 'automated', result: result.result, + runId: request.runId, actor: 'local-node-runner', startedAt, completedAt, provenance: 'runner-verified', supersedes: null, + attachments: [], summary: result.detail, + }; + }); + return signScenarioResult({ protocolVersion: 1, requestDigest: digest, runId: request.runId, evidence, completedAt, execution: verified.execution, network: verified.network, controlPlaneMounted: false }, options.jobKey); + } finally { await rm(candidate, { recursive: true, force: true }); } + }; + const running = previous?.result ?? execute(); + if (!previous) runs.set(request.runId, { digest, result: running }); + return send(200, await running); + })().catch(() => send(400, { error: 'local_verification_refused', detail: 'request signature, source, runtime, or isolation check failed' })); + }); + server.requestTimeout = 15000; + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', resolve); }); + return { server, url: `http://127.0.0.1:${(server.address() as { port: number }).port}`, close: () => new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())) }; +} diff --git a/apps/runner/src/g1-local-preview.ts b/apps/runner/src/g1-local-preview.ts new file mode 100644 index 0000000..139108f --- /dev/null +++ b/apps/runner/src/g1-local-preview.ts @@ -0,0 +1,22 @@ +/** Narrow standalone-module preview backend. No source-provider/control-plane authority. */ +import { createServer } from 'node:http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { requestIsolatedCandidate } from './g1-local-verifier.js'; +export async function startLocalPreview(options: { key: string; port?: number }) { + const server = createServer((req, res) => { void (async () => { + if (req.method !== 'POST' || req.url !== '/serve' || req.headers.origin || req.headers.authorization !== `Bearer ${options.key}`) { res.writeHead(403); res.end(); return; } + let size = 0; const chunks: Buffer[] = []; for await (const chunk of req) { size += (chunk as Buffer).length; if (size > 1600000) throw new Error('preview too large'); chunks.push(chunk as Buffer); } + const input = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { module: string; path: string; method: string; body?: string }; + if (typeof input.module !== 'string' || input.module.length > 1400000 || typeof input.path !== 'string' || typeof input.method !== 'string' || (input.body !== undefined && (typeof input.body !== 'string' || input.body.length > 65536))) throw new Error('invalid preview request'); + const root = await mkdtemp(join(tmpdir(), 'g1-preview-api-')); + try { + await writeFile(join(root, 'worker.mjs'), Buffer.from(input.module, 'base64'), { mode: 0o400 }); + const response = await requestIsolatedCandidate(root, input.path, input.method, input.body); + res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(response)); + } finally { await rm(root, { recursive: true, force: true }); } + })().catch(() => { if (!res.headersSent) { res.writeHead(503); res.end('isolated local preview unavailable'); } }); }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', resolve); }); + return { url: `http://127.0.0.1:${(server.address() as { port:number }).port}`, close: () => new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())) }; +} diff --git a/apps/runner/src/g1-local-verifier.ts b/apps/runner/src/g1-local-verifier.ts new file mode 100644 index 0000000..8f0e55c --- /dev/null +++ b/apps/runner/src/g1-local-verifier.ts @@ -0,0 +1,116 @@ +/** Real Linux verifier. The supervisor owns scenarios; only candidate code enters the sandbox. */ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { request } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { cases } from './g1-scheduling-harness.js'; + +export interface LocalScenarioResult { scenarioId: string; result: 'pass' | 'fail' | 'error'; detail: string } +export interface LocalVerification { + verifierDigest: string; + sourceDigest: string; + execution: 'bubblewrap-linux-http'; + network: 'none'; + controlPlaneMounted: false; + results: LocalScenarioResult[]; +} + +const hostPath = fileURLToPath(new URL('../src/g1-candidate-host.mjs', import.meta.url)); +export async function schedulingVerifierDigest(): Promise { + // Bind the supervisor's comparison, isolation and timeout logic too, not just the examples. + // Source-mode and compiled supervisors intentionally have different verifier definitions. + return createHash('sha256').update('cf.scheduling-verifier/2\0').update(JSON.stringify(cases)) + .update(await readFile(hostPath)).update(await readFile(new URL(import.meta.url))).digest('hex'); +} + +interface SocketResponse { status: number; headers: Record; body: string } +function socketResponse(socketPath: string, path: string, method: string, body?: string): Promise { + return new Promise((resolve, reject) => { + const req = request({ socketPath, path, method, headers: { 'content-type': 'application/json' }, timeout: 2000 }, (res) => { + const chunks: Buffer[] = []; let bytes = 0; + res.on('data', (chunk: Buffer) => { bytes += chunk.length; if (bytes > 64 * 1024) res.destroy(new Error('response too large')); else chunks.push(chunk); }); + res.on('error', reject); + res.on('end', () => resolve({ status: res.statusCode ?? 500, headers: Object.fromEntries(Object.entries(res.headers).filter((e): e is [string,string] => typeof e[1] === 'string')), body: Buffer.concat(chunks).toString('base64') })); + }); + req.on('timeout', () => req.destroy(new Error('candidate timeout'))); req.on('error', reject); + req.end(method === 'GET' ? undefined : body); + }); +} +async function socketRequest(socketPath: string, body: unknown, path = '/reschedule', method = 'POST'): Promise { + const response = await socketResponse(socketPath, path, method, JSON.stringify(body)); + if (response.status !== 200) throw new Error(`HTTP ${response.status}`); + return JSON.parse(Buffer.from(response.body,'base64').toString('utf8')); +} + +/** No unsafe fallback. Missing namespaces/bwrap is an unavailable environmental check. */ +async function withCandidateSandbox(candidateDir: string, observe: (socketPath: string, sourceDigest: string) => Promise): Promise { + const dir = await realpath(candidateDir); + const worker = join(dir, 'worker.mjs'); + const handle = await open(worker, constants.O_RDONLY | constants.O_NOFOLLOW); + let bytes: Buffer; + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024) throw new Error('candidate worker must be a bounded plain file'); + bytes = await handle.readFile(); + if (bytes.length > 1024 * 1024) throw new Error('candidate grew beyond size bound'); + } finally { await handle.close(); } + const sourceDigest = createHash('sha256').update(bytes).digest('hex'); + const scratch = await mkdtemp(join(tmpdir(), 'commonforge-g1-sandbox-')); + await chmod(scratch, 0o700); + const snapshot = join(scratch, 'worker.mjs'); + const socketDir = join(scratch, 'socket'); + await writeFile(snapshot, bytes, { mode: 0o400 }); + await mkdir(socketDir, { mode: 0o700 }); + const node = await realpath(process.execPath); + const child = spawn('bwrap', [ + '--unshare-all', '--die-with-parent', '--new-session', '--cap-drop', 'ALL', + '--ro-bind', '/usr', '/usr', '--ro-bind', '/lib', '/lib', '--ro-bind', '/lib64', '/lib64', + '--ro-bind', node, '/node', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', + '--ro-bind', snapshot, '/candidate/worker.mjs', '--ro-bind', hostPath, '/host.mjs', + '--bind', socketDir, '/scratch', '--chdir', '/candidate', '/node', '/host.mjs', + ], { env: { PATH: '/usr/bin:/bin', HOME: '/tmp', TZ: 'UTC' }, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + let stderr = ''; + child.stderr.on('data', (b: Buffer) => { stderr = (stderr + b.toString()).slice(-2000); }); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('candidate startup timed out')), 5000); + child.once('error', (e) => { clearTimeout(timer); reject(e); }); + child.once('exit', (code) => { clearTimeout(timer); reject(new Error(`candidate exited ${code}: ${stderr}`)); }); + child.stdout.once('data', () => { clearTimeout(timer); resolve(); }); + }); + return await observe(join(socketDir, 'preview.sock'), sourceDigest); + } finally { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* already exited */ } + if (child.exitCode === null && child.signalCode === null) await new Promise((resolve) => child.once('exit', () => resolve())); + await rm(scratch, { recursive: true, force: true }); + } +} + +export async function verifySchedulingCandidate(candidateDir: string): Promise { + return withCandidateSandbox(candidateDir, async (socketPath, sourceDigest) => { + const results: LocalScenarioResult[] = []; + for (const [scenarioId, input, expected] of cases) { + try { + const actual = await socketRequest(socketPath, { now: '2026-10-17T12:00:00.000Z', ...input }); + const same = JSON.stringify(actual) === JSON.stringify(expected); + results.push({ scenarioId, result: same ? 'pass' : 'fail', detail: same ? 'expected HTTP behavior observed' : 'HTTP response differs from trusted expectation' }); + } catch { results.push({ scenarioId, result: 'error', detail: 'candidate HTTP request failed' }); } + } + return { verifierDigest: await schedulingVerifierDigest(), sourceDigest, execution: 'bubblewrap-linux-http', network: 'none', controlPlaneMounted: false, results }; + }); +} + +/** Supplemental real HTTP observation; does not claim to replace signed required scenarios. */ +export async function inspectIsolatedHealth(candidateDir: string): Promise { + return withCandidateSandbox(candidateDir, socket => socketRequest(socket, undefined, '/health', 'GET')); +} + +/** One bounded real preview HTTP request in a fresh isolated candidate process. */ +export async function requestIsolatedCandidate(candidateDir: string, path: string, method: string, body?: string): Promise { + if (!['GET','POST'].includes(method) || !path.startsWith('/') || /[\r\n\0]/.test(path) || path.length > 2048) throw new Error('unsupported preview request'); + return withCandidateSandbox(candidateDir, socket => socketResponse(socket, path, method, body)); +} diff --git a/apps/runner/src/g1-pending-review.ts b/apps/runner/src/g1-pending-review.ts new file mode 100644 index 0000000..4342f05 --- /dev/null +++ b/apps/runner/src/g1-pending-review.ts @@ -0,0 +1,37 @@ +/** Prepare an actual independently verified source proposal for owner browser review. */ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createGit, RUNNER_IDENTITY_ENV } from './git.js'; +const session = JSON.parse(await readFile(process.argv[2]!, 'utf8')) as { ownerToken: string; organizationId: string; projectId: string }; +const operator = JSON.parse(await readFile(process.argv[3]!, 'utf8')) as { workerUrl: string }; +async function api(method: string, path: string, body?: unknown, token = session.ownerToken): Promise { + const res = await fetch(`${operator.workerUrl}/api${path}`, { method, headers: { authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + const data = await res.json(); if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${JSON.stringify(data)}`); return data; +} +async function waitFor(path: string, ready: (data: any) => boolean) { const end = Date.now() + 60000; while (Date.now() < end) { const data = await api('GET', path); if (ready(data)) return data; await new Promise(r => setTimeout(r, 250)); } throw new Error('timed out'); } +const native = `/orgs/${session.organizationId}/projects/${session.projectId}/native`; +const task = await api('POST', `/orgs/${session.organizationId}/projects/${session.projectId}/tasks`, { title: 'Review health metadata follow-up', request: 'Expose review readiness in synthetic fixture health while preserving all corrected scheduling rules.', writeMounts: ['app'] }); +const ready = await waitFor(`/tasks/${task.taskId}`, data => data.state === 'ready'); +const handoff = await api('POST', `/tasks/${task.taskId}/native/handoff`, { agentId: 'browser-review-preparation-client' }); +const agent = await api('POST', '/auth/task/verify', { code: handoff.code }); +const credential = await api('POST', `/tasks/${task.taskId}/credential`, { mountName: 'app', access: 'write' }, agent.leaseToken); +const root = await mkdtemp(join(tmpdir(), 'g1-pending-browser-review-')), work = join(root, 'candidate'), git = createGit({ allowHttp: true }); +const env = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.${credential.remoteUrl}.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Bearer ${credential.secret}` }; +await git.ok(['clone', '-q', credential.remoteUrl, work], root, { env }); +const nested = join(work, 'fixtures/scheduling/worker.mjs'); +const file = await readFile(nested).then(() => nested, () => join(work, 'worker.mjs')); +await writeFile(file, (await readFile(file, 'utf8')).replace('ruleVersion: 2', 'ruleVersion: 2, reviewReady: true')); +await git.ok(['add', '.'], work); await git.ok(['commit', '-q', '-m', 'Expose review readiness in fixture health'], work, { env: RUNNER_IDENTITY_ENV(new Date().toISOString()) }); +const sha = (await git.ok(['rev-parse', 'HEAD'], work)).trim(); await git.ok(['push', '-q', 'origin', 'HEAD:refs/heads/main'], work, { env }); +const ws = ready.workspaces.find((w: any) => w.mountName === 'app'); +const candidate = await api('POST', `/tasks/${task.taskId}/workspaces/${ws.taskWorkspaceId}/freeze`, { expectedSha: sha, tool: 'real-browser-review-preparation' }, agent.leaseToken); +const inner = await api('POST', `/orgs/${session.organizationId}/proposals`, { taskId: task.taskId, candidateIds: [candidate.candidateId] }); +const innerPath = `/orgs/${session.organizationId}/proposals/${inner.proposalId}`; +await waitFor(innerPath, data => data.state === 'prepared'); await api('POST', `${innerPath}/integrate`, {}); await waitFor(innerPath, data => !!data.integration); +const state = (await api('GET', native)).state; +const outer = await api('POST', `/tasks/${task.taskId}/native/proposals`, { innerProposalId: inner.proposalId, expectedGeneration: state.generation, idempotencyKey: `browser-outer-${task.taskId}` }, agent.leaseToken); +const verification = await api('POST', `${native}/proposals/${encodeURIComponent(outer.proposal.id)}/verify`, { idempotencyKey: `browser-verify-${task.taskId}` }); +if (!verification.evidence.every((e: any) => e.result === 'pass')) throw new Error('behavioral verification failed'); +const report = { root, organizationId: session.organizationId, projectId: session.projectId, taskId: task.taskId, innerProposalId: inner.proposalId, proposalId: outer.proposal.id, envelopeDigest: outer.proposal.digest, sourceRevision: sha, scenarioCount: verification.evidence.length, accepted: false, status: 'awaiting-owner-review' }; +await writeFile(join(root, 'report.json'), JSON.stringify(report, null, 2)); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/apps/runner/src/g1-real-local.ts b/apps/runner/src/g1-real-local.ts new file mode 100644 index 0000000..ebe4b46 --- /dev/null +++ b/apps/runner/src/g1-real-local.ts @@ -0,0 +1,93 @@ +/** Reproducible real Git/Node-runner/HTTP-verifier spike; does not simulate cloud release. */ +import { randomBytes } from 'node:crypto'; +import { createServer } from 'node:http'; +import { copyFile, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LocalGitProvider } from '@commonforge-org/provider-local-git'; +import { localRevisionId } from '@commonforge-org/core'; +import { scenarioBuildDigest, signScenarioRequest, verifyScenarioResult, type JobSigningKey, type ScenarioVerificationRequest } from '@commonforge-org/runner-protocol'; +import { createGit, RUNNER_IDENTITY_ENV } from './git.js'; +import { createRunner } from './runner.js'; +import { staticCredentials } from './credentials.js'; +import { fsBlobStore } from './blob-store.js'; +import { createRunnerSubmit } from './submit.js'; +import { schedulingVerifierDigest } from './g1-local-verifier.js'; +import { SCHEDULING_RUNTIME_RECIPE_DIGEST, startLocalScenarioBridge } from './g1-local-bridge.js'; +import { localNamespaceCommands } from './g1-bwrap-commands.js'; +import { cases } from './g1-scheduling-harness.js'; + +export async function runRealLocal(options: { port?: number; bridgePort?: number; jobKey?: JobSigningKey; organizationId?: string; projectId?: string } = {}) { + const root = await mkdtemp(join(tmpdir(), 'commonforge-g1-real-local-')); + const seed = join(root, 'seed'); await mkdir(seed); + for (const file of ['worker.mjs', 'build.mjs']) await copyFile(new URL(`../../../fixtures/scheduling/${file}`, import.meta.url), join(seed, file)); + const git = createGit({ allowFile: true }); + await git.ok(['init', '-q', '-b', 'main'], seed); + await git.ok(['add', '.'], seed); + await git.ok(['commit', '-q', '-m', 'Synthetic scheduling reference'], seed, { env: RUNNER_IDENTITY_ENV(new Date().toISOString()) }); + const provider = await LocalGitProvider.open({ root: join(root, 'repos'), namespacePrefix: 'commonforge-g1' }); + const organizationId = options.organizationId ?? 'g1-local-org', projectId = options.projectId ?? 'g1-scheduling'; + const resource = await provider.createCanonicalResource({ organizationId, resourceId: 'scheduling', source: seed }); + if (resource.coords.kind !== 'local-git') throw new Error('local coords expected'); + const revision = await provider.resolveRevision(resource, { kind: 'ref', ref: 'refs/heads/main' }); + const jobKey = options.jobKey ?? { kid: 'k1', secret: randomBytes(32).toString('hex') }; + await writeFile(join(root, 'job-key.json'), JSON.stringify(jobKey), { mode: 0o600 }); + const serviceEnv = await provider.serviceGitEnv(); + // Only the fixed audited fixture build executes here. Hostile code uses the namespace verifier. + const runner = createRunner({ workDir: join(root, 'runner'), credentials: staticCredentials(() => serviceEnv), + jobKeyring: [jobKey], git: { allowFile: true }, commands: await localNamespaceCommands(join(root, 'sandbox-bin')), + blobStore: fsBlobStore(join(root, 'blobs')), previewBaseUrl: 'http://127.0.0.1', runnerId: 'g1-real-local-node', log: () => undefined }); + const submit = createRunnerSubmit({ runner, jobKey }); + const buildResult = await submit({ kind: 'preview-build', previewId: 'scheduling', + integrationSha: { scheduling: revision.contentId }, sources: [{ resourceId: 'scheduling', mountName: 'app', canonical: resource.coords }], + recipe: { recipeId: 'audited-scheduling-build', revision: 1, steps: [{ stepId: 'build', mountName: 'app', command: 'node build.mjs' }], output: { mountName: 'app', path: 'dist' } }, + bundles: [{ name: 'worker', mountName: 'app', path: 'dist' }], + }); + if (!buildResult.ok) throw new Error(`real runner preview build failed: ${JSON.stringify(buildResult)}`); + const preview = createServer((_req, res) => { void readFile(join(root, 'blobs/previews/scheduling/site/index.html')).then(data => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(data); }, () => { res.writeHead(503); res.end('preview not available'); }); }); + await new Promise((resolve, reject) => { preview.once('error', reject); preview.listen(options.port ?? 0, '127.0.0.1', resolve); }); + const previewUrl = `http://127.0.0.1:${(preview.address() as { port: number }).port}/`; + let bridge: Awaited>; + try { bridge = await startLocalScenarioBridge({ provider, jobKey, port: options.bridgePort, resources: [{ organizationId, projectId, resourceId: 'scheduling' }] }); } + catch (e) { preview.close(); throw e; } + try { + const now = new Date(); + const sourceHeads = [{ resourceId: 'scheduling', revisionId: localRevisionId(resource.coords.repoId, revision.contentId) }]; + const request: ScenarioVerificationRequest = { protocolVersion: 1, organizationId, projectId, + runId: `local-${Date.now()}`, envelopeDigest: 'a'.repeat(64), buildDigest: scenarioBuildDigest(sourceHeads), + runtimeRecipeDigest: SCHEDULING_RUNTIME_RECIPE_DIGEST, sourceHeads, + requiredScenarios: cases.map(c => ({ id: c[0], revision: 1, verifierDigest: '', method: 'automated' })), + issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + 60000).toISOString() }; + const verifierDigest = await schedulingVerifierDigest(); + request.requiredScenarios.forEach(s => { s.verifierDigest = verifierDigest; }); + const result = await fetch(`${bridge.url}/verify`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(await signScenarioRequest(request, jobKey)) }); + if (!result.ok) throw new Error(`bridge verification HTTP ${result.status}: ${await result.text()}`); + const signedResult = await result.json(); + const verified = await verifyScenarioResult(signedResult, jobKey, request); + const previewResponse = await fetch(previewUrl); + if (!previewResponse.ok || !(await previewResponse.text()).includes('Synthetic scheduling')) throw new Error('real HTTP preview failed'); + const report = { protocolVersion: 1, mode: 'real-local', root, organizationId, projectId, sourceHeads, + previewUrl, bridgeUrl: bridge.url, verifierDigest, runtimeRecipeDigest: SCHEDULING_RUNTIME_RECIPE_DIGEST, + gates: { realGit: 'pass', signedNodeRunner: 'pass', localBlobStore: 'pass', httpPreview: 'pass', trustedIsolatedScenarios: verified.evidence.every(e => e.result === 'pass') ? 'pass' : 'fail', workerWorkflow: 'not_run', cloudRelease: 'not_run', freshAgent: 'not_run' }, + runnerBuild: buildResult, verification: verified, limitations: ['Only Node build/check entrypoints are supported by the local namespace wrapper; package installs and other command families fail closed.', 'Standalone provider/runner rehearsal does not itself prove the Worker acceptance workflow.', 'Local preview is not a cloud release.'], + }; + await writeFile(join(root, 'report.json'), JSON.stringify(report, null, 2)); + await writeFile(join(root, 'signed-verification.json'), JSON.stringify(signedResult, null, 2)); + return { report, provider, resource, close: async () => { await bridge.close(); await new Promise((resolve, reject) => preview.close(e => e ? reject(e) : resolve())); } }; + } catch (e) { await bridge.close(); preview.close(); throw e; } +} + +async function main() { + const keyFile = process.env.COMMONFORGE_LOCAL_JOB_KEY_FILE; + const jobKey = keyFile ? JSON.parse(await readFile(resolve(keyFile), 'utf8')) as JobSigningKey : undefined; + const running = await runRealLocal({ port: Number(process.env.PREVIEW_PORT ?? 0), bridgePort: Number(process.env.BRIDGE_PORT ?? 0), jobKey, + organizationId: process.env.G1_ORGANIZATION_ID, projectId: process.env.G1_PROJECT_ID }); + process.stdout.write(`${JSON.stringify(running.report, null, 2)}\n`); + if (!process.argv.includes('--serve')) await running.close(); + else { + const stop = async () => { await running.close(); process.exit(0); }; + process.once('SIGINT', () => { void stop(); }); process.once('SIGTERM', () => { void stop(); }); + } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) void main().catch(e => { process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`); process.exitCode = 1; }); diff --git a/apps/runner/src/g1-scheduling-harness.ts b/apps/runner/src/g1-scheduling-harness.ts new file mode 100644 index 0000000..124bf95 --- /dev/null +++ b/apps/runner/src/g1-scheduling-harness.ts @@ -0,0 +1,14 @@ +// Trusted supervisor fixture, mounted read-only OUTSIDE the candidate tree. +// Candidate code runs only in the sibling process; it cannot change these expectations. +const cases: Array<[string, Record, Record]> = [ + ['exact-cutoff', { appointment: '2026-10-18T12:00:00.000Z', actor: 'customer' }, { allowed: true, override: null }], + ['inside-cutoff', { appointment: '2026-10-18T11:59:59.999Z', actor: 'customer' }, { allowed: false, code: 'cutoff' }], + ['outside-cutoff', { appointment: '2026-10-18T12:00:00.001Z', actor: 'customer' }, { allowed: true, override: null }], + ['travel-block', { appointment: '2026-10-19T12:00:00.000Z', actor: 'customer', traveling: true }, { allowed: false, code: 'technician_traveling' }], + ['unauthorized-override', { appointment: '2026-10-17T13:00:00.000Z', actor: 'customer', reason: 'Please' }, { allowed: false, code: 'cutoff' }], + ['manager-reason-required', { appointment: '2026-10-17T13:00:00.000Z', actor: 'manager', reason: ' ' }, { allowed: false, code: 'override_reason_required' }], + ['manager-reason-retained', { appointment: '2026-10-17T13:00:00.000Z', actor: 'manager', reason: ' Customer emergency ' }, { allowed: true, override: { actor: 'manager', reason: 'Customer emergency', recordedAt: '2026-10-17T12:00:00.000Z' } }], + ['manager-no-travel-bypass', { appointment: '2026-10-17T13:00:00.000Z', actor: 'manager', reason: 'Emergency', traveling: true }, { allowed: false, code: 'technician_traveling' }], + ['explicit-offset', { appointment: '2026-10-18T07:00:00.000-05:00', actor: 'customer' }, { allowed: true, override: null }], +]; +export { cases }; diff --git a/apps/runner/src/g1-worker-rehearsal.ts b/apps/runner/src/g1-worker-rehearsal.ts new file mode 100644 index 0000000..f6ed7d3 --- /dev/null +++ b/apps/runner/src/g1-worker-rehearsal.ts @@ -0,0 +1,141 @@ +/** Concrete public API + real Git rehearsal against scripts/dev-real-worker.sh. No fixture API. */ +import { copyFile, lstat, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createGit, RUNNER_IDENTITY_ENV } from './git.js'; +import { inspectIsolatedHealth } from './g1-local-verifier.js'; +import { cases } from './g1-scheduling-harness.js'; +interface Operator { sidecarUrl: string; sidecarKey: string; bridgeUrl: string; workerUrl: string; namespacePrefix: string } +export async function rehearseWorker(operatorFile: string, logFile: string, options: { selfDevelopment?: boolean; ownerSessionFile?: string; preview?: boolean } = {}) { + const operator = JSON.parse(await readFile(operatorFile, 'utf8')) as Operator; + const root = await mkdtemp(join(tmpdir(), 'commonforge-g1-worker-rehearsal-')); + process.stderr.write(`real Worker rehearsal artifacts: ${root}\n`); + const events: Array<{ operation: string; status: number }> = []; + let ownerToken = ''; + async function api(method: string, path: string, body?: unknown, token = ownerToken): Promise { + const res = await fetch(`${operator.workerUrl}/api${path}`, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }); + events.push({ operation: `${method} ${path}`, status: res.status }); + const result = await res.json(); if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${JSON.stringify(result)}`); return result; + } + async function waitFor(operation: () => Promise, accepted: (v: T) => boolean, label: string, timeout = 60000): Promise { + const end = Date.now() + timeout; let last: T | undefined; + while (Date.now() < end) { last = await operation(); if (accepted(last)) return last; await new Promise(r => setTimeout(r, 250)); } + throw new Error(`timeout waiting for ${label}: ${JSON.stringify(last)}`); + } + let login: any; + if (options.ownerSessionFile) { + ownerToken = JSON.parse(await readFile(options.ownerSessionFile, 'utf8')).ownerToken; + login = await api('GET', '/me'); + } else { + await api('POST', '/auth/magic-link', { email: 'owner@agency.e2e.test', client: 'cli' }, ''); + const code = await waitFor(async () => (await readFile(logFile, 'utf8')).match(/magic link for owner@agency\.e2e\.test: .*?[?&](?:code|token)=(cfm_[A-Za-z0-9_-]+)/g)?.at(-1)?.match(/(cfm_[A-Za-z0-9_-]+)/)?.[1], v => !!v, 'local magic link'); + login = await api('POST', '/auth/cli/verify', { code }, ''); ownerToken = login.sessionToken; + } + const org = await api('POST', '/orgs', { name: 'G1 real-source rehearsal' }), orgId = org.orgId; + const project = await api('POST', `/orgs/${orgId}/projects`, { name: options.selfDevelopment ? 'CommonForge own development' : 'Synthetic scheduling' }), projectId = project.projectId; + const resource = await api('POST', `/orgs/${orgId}/resources`, { name: 'Scheduling fixture', provider: 'artifacts' }); + let sourceSnapshot: { commit: string; fileCount: number; exclusions: string[] } | null = null; + if (options.selfDevelopment) { + const publicRepo = fileURLToPath(new URL('../../../', import.meta.url)); + const seed = join(root, 'public-source-snapshot'); await mkdir(seed); + const git = createGit({ allowHttp: true }); + const paths = (await git.ok(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], publicRepo)).split('\0').filter(Boolean); + let fileCount = 0; const exclusions: string[] = []; + for (const path of paths) { + if (/(?:^|\/)(?:\.env(?:$|\.)|\.dev\.vars|credentials|node_modules|\.wrangler|\.g1-artifacts)(?:\/|$)/.test(path) || path.startsWith('.git/')) { exclusions.push(path); continue; } + const source = join(publicRepo, path), stat = await lstat(source).catch(() => null); + if (!stat?.isFile()) { exclusions.push(path); continue; } + await mkdir(dirname(join(seed, path)), { recursive: true }); await copyFile(source, join(seed, path)); fileCount++; + } + await git.ok(['init', '-q', '-b', 'main'], seed); await git.ok(['add', '.'], seed); + await git.ok(['commit', '-q', '-m', 'Isolated public CommonForge source snapshot for G1 self-development'], seed, { env: RUNNER_IDENTITY_ENV(new Date().toISOString()) }); + const tokenResponse = await fetch(`${operator.sidecarUrl}/rpc`, { method: 'POST', headers: { authorization: `Bearer ${operator.sidecarKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ method: 'createToken', args: [resource.coords.namespace, resource.coords.repoName, 'write', 300] }) }); + if (!tokenResponse.ok) throw new Error('real source bootstrap credential refused'); + const token = (await tokenResponse.json() as { result: { plaintext: string; id: string } }).result; + await git.ok(['push', '-q', resource.coords.remoteUrl, 'HEAD:refs/heads/main'], seed, { env: { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.${resource.coords.remoteUrl}.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Bearer ${token.plaintext}` } }); + await fetch(`${operator.sidecarUrl}/rpc`, { method: 'POST', headers: { authorization: `Bearer ${operator.sidecarKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ method: 'revokeToken', args: [resource.coords.namespace, resource.coords.repoName, token.id] }) }); + sourceSnapshot = { commit: (await git.ok(['rev-parse', 'HEAD'], seed)).trim(), fileCount, exclusions }; + } else { + const rpc = await fetch(`${operator.sidecarUrl}/rpc`, { method: 'POST', headers: { authorization: `Bearer ${operator.sidecarKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ method: 'seedScheduling', args: [resource.coords.namespace, resource.coords.repoName] }) }); + if (!rpc.ok) throw new Error('real Git fixture bootstrap failed'); + } + await api('POST', `/orgs/${orgId}/resources/${resource.resourceId}/adopt-head`, {}); + await api('POST', `/orgs/${orgId}/projects/${projectId}/mounts`, { mountName: 'app', resourceId: resource.resourceId, operations: ['read', 'write'] }); + if (options.preview) await api('PUT', `/orgs/${orgId}/projects/${projectId}/policy`, { preview: { recipe: { recipeId: 'scheduling-preview', revision: 1, steps: [{ stepId: 'build', mountName: 'app', command: 'node build.mjs' }], output: { mountName: 'app', path: 'dist' } }, bundles: [{ name: 'api', mountName: 'app', path: 'dist' }], hostVars: {}, ttlSeconds: 3600 } }); + const native = `/orgs/${orgId}/projects/${projectId}/native`; + let state = (await api('POST', `${native}/enable`, { idempotencyKey: 'enable' })).state; + await writeFile(join(root, 'owner-session.json'), JSON.stringify({ ownerToken, organizationId: orgId, projectId, resourceId: resource.resourceId }), { mode: 0o600 }); + const author = login.user.userId; + const base = (id: string) => ({ schemaVersion: 1, organizationId: orgId, projectId, id, author, createdAt: new Date().toISOString() }); + async function command(command: Record): Promise { const result = await api('POST', `${native}/commands`, { ...command, expectedGeneration: state.generation, idempotencyKey: `cmd-${state.generation}` }); state = result.state; return result; } + await command({ type: 'draftKnowledge', record: { ...base('reschedule-rule'), revision: 1, predecessor: null, kind: 'rule', text: 'Customers may reschedule at least 24 hours before an appointment, before travel begins.', rationale: 'Owner requested deterministic scheduling rules', sources: [{ kind: 'text', ref: 'Synthetic owner request', digest: null }], confirmation: null, archived: false } }); + await command({ type: 'confirmKnowledge', id: 'reschedule-rule', revision: 1, decisionId: 'confirm-initial-rule', explanation: 'Confirmed explicit cutoff and travel boundary' }); + const capability = await (await fetch(`${operator.bridgeUrl}/capabilities`)).json() as { verifierDigest: string }; + for (const [id] of cases.slice(0, 4)) { + await command({ type: 'draftScenario', record: { ...base(id), revision: 1, predecessor: null, title: id, given: 'Synthetic UTC clock and appointment', when: 'A customer requests rescheduling', then: 'Enforce the cutoff and travel rule', ruleRefs: [{ id: 'reschedule-rule', revision: 2 }], required: true, method: 'automated', verifierDigest: capability.verifierDigest, confirmation: null, archived: false } }); + await command({ type: 'confirmScenario', id, revision: 1, decisionId: `confirm-${id}`, explanation: 'Confirmed synthetic behavioral example' }); + } + await command({ type: 'upsertOutcome', record: { ...base('health-metadata-outcome'), revision: 1, description: 'Expose the scheduling rule version in fixture health while preserving the owner correction.', owner: author, successCriteria: ['Health reports the implementation rule version', 'All confirmed scheduling scenarios still pass'], state: 'open', taskIds: [] } }); + async function cycle(label: string, edit: (source: string) => string) { + const task = await api('POST', `/orgs/${orgId}/projects/${projectId}/tasks`, { title: label, request: label, writeMounts: ['app'] }); + const ready = await waitFor(() => api('GET', `/tasks/${task.taskId}`), t => t.state === 'ready', 'real task seeding'); + const handoff = await api('POST', `/tasks/${task.taskId}/native/handoff`, { agentId: `rehearsal-${label}` }); + const agent = await api('POST', '/auth/task/verify', { code: handoff.code }, ''); + const context = await api('GET', `/tasks/${task.taskId}/native/context`, undefined, agent.leaseToken); + const credential = await api('POST', `/tasks/${task.taskId}/credential`, { mountName: 'app', access: 'write' }, agent.leaseToken); + const work = join(root, `work-${label}`), git = createGit({ allowHttp: true }); + const env = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.${credential.remoteUrl}.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Bearer ${credential.secret}` }; + await git.ok(['clone', '-q', credential.remoteUrl, work], root, { env }); + const workerPath = join(work, options.selfDevelopment ? 'fixtures/scheduling/worker.mjs' : 'worker.mjs'); + await writeFile(workerPath, edit(await readFile(workerPath, 'utf8'))); + await git.ok(['add', '.'], work); await git.ok(['commit', '-q', '-m', label], work, { env: RUNNER_IDENTITY_ENV(new Date().toISOString()) }); + const sha = (await git.ok(['rev-parse', 'HEAD'], work)).trim(); await git.ok(['push', '-q', 'origin', 'HEAD:refs/heads/main'], work, { env }); + const ws = ready.workspaces.find((w: any) => w.mountName === 'app'); + const candidate = await api('POST', `/tasks/${task.taskId}/workspaces/${ws.taskWorkspaceId}/freeze`, { expectedSha: sha, tool: 'g1-real-api-rehearsal' }, agent.leaseToken); + const inner = await api('POST', `/orgs/${orgId}/proposals`, { taskId: task.taskId, candidateIds: [candidate.candidateId] }); + await waitFor(() => api('GET', `/orgs/${orgId}/proposals/${inner.proposalId}`), p => p.state === 'prepared', 'candidate preparation'); + await api('POST', `/orgs/${orgId}/proposals/${inner.proposalId}/integrate`, {}); + await waitFor(() => api('GET', `/orgs/${orgId}/proposals/${inner.proposalId}`), p => !!p.integration, 'real Node integration'); + state = (await api('GET', native)).state; + const outer = await api('POST', `/tasks/${task.taskId}/native/proposals`, { innerProposalId: inner.proposalId, expectedGeneration: state.generation, idempotencyKey: `outer-${label}` }, agent.leaseToken); + state = outer.state; + const verified = await api('POST', `${native}/proposals/${encodeURIComponent(outer.proposal.id)}/verify`, { idempotencyKey: `verify-${label}` }); state = verified.state; + if (!verified.evidence.every((e: any) => e.result === 'pass')) throw new Error('trusted scenarios did not pass'); + const health = await inspectIsolatedHealth(dirname(workerPath)) as { ruleVersion?: number }; + if (health.ruleVersion !== (label === 'initial' ? 1 : 2)) throw new Error('independent health metadata observation failed'); + const proposal = outer.proposal; + await command({ type: 'recordDecision', record: { ...base(`approve-${label}`), envelopeDigest: proposal.digest, innerProposalHash: proposal.envelope.innerProposalHash, action: 'approve', explanation: 'Reviewed exact real runner behavior', authorityRevision: state.authorities.find((a: any) => a.principalId === author).revision } }); + await command({ type: 'accept', proposalId: proposal.id, decisionId: `approve-${label}` }); + return { taskId: task.taskId, innerProposalId: inner.proposalId, proposalId: proposal.id, contextDigest: agent.context.digest, contextRuleRevisions: context.context.packet.rules.map((r: any) => ({ id: r.id, revision: r.revision })), sourceRevision: sha, supplementalObservedHealth: health, scenarioCount: verified.evidence.length, acceptedManifest: state.manifests.at(-1).hash }; + } + const initial = await cycle('initial', source => source.replace('fixture: true, clock: CLOCK', 'fixture: true, ruleVersion: 1, clock: CLOCK')); + await command({ type: 'draftKnowledge', record: { ...base('reschedule-rule'), revision: 3, predecessor: 2, kind: 'rule', text: 'Managers may override the 24-hour cutoff only with a retained nonempty reason. Travel still blocks everybody.', rationale: 'Owner correction persists beyond the original attempt', sources: [{ kind: 'text', ref: 'Synthetic owner correction', digest: null }], confirmation: null, archived: false } }); + await command({ type: 'confirmKnowledge', id: 'reschedule-rule', revision: 3, decisionId: 'confirm-manager-correction', explanation: 'Require manager exception and retain reason' }); + for (const [id] of cases.slice(4)) { + await command({ type: 'draftScenario', record: { ...base(id), revision: 1, predecessor: null, title: id, given: 'Synthetic manager/customer and appointment', when: 'Rescheduling is requested', then: 'Preserve authorization, reason, and travel restriction', ruleRefs: [{ id: 'reschedule-rule', revision: 4 }], required: true, method: 'automated', verifierDigest: capability.verifierDigest, confirmation: null, archived: false } }); + await command({ type: 'confirmScenario', id, revision: 1, decisionId: `confirm-${id}`, explanation: 'Confirmed corrected behavioral example' }); + } + const corrected = await cycle('corrected', source => source.replace('ruleVersion: 1', 'ruleVersion: 2')); + let previewEvidence: unknown = null; + if (options.preview) { + const queued = await api('POST', `/orgs/${orgId}/proposals/${corrected.innerProposalId}/previews`, {}); + const ready = await waitFor(() => api('GET', `/orgs/${orgId}/previews/${queued.previewId}`), p => p.state === 'ready', 'real control-plane preview'); + const staticResponse = await fetch(ready.url); + if (!staticResponse.ok || !(await staticResponse.text()).includes('Synthetic scheduling')) throw new Error('real R2 static preview failed'); + const healthResponse = await fetch(`${ready.url}api/health`); + const health = await healthResponse.json() as { ruleVersion?: number }; + if (!healthResponse.ok || health.ruleVersion !== 2) throw new Error('real isolated API preview health failed'); + const managerResponse = await fetch(`${ready.url}api/reschedule`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ actor: 'manager', appointment: '2026-10-17T13:00:00.000Z', reason: 'Preview override' }) }); + const manager = await managerResponse.json() as { allowed?: boolean; override?: {reason?:string} }; + if (!managerResponse.ok || !manager.allowed || manager.override?.reason !== 'Preview override') throw new Error('real isolated API preview manager rule failed'); + previewEvidence = { previewId: queued.previewId, sourceBinding: ready.integrationSha ?? null, separateOrigin: new URL(ready.url).origin !== new URL(operator.workerUrl).origin, staticHttpStatus: staticResponse.status, apiHealth: health, managerResponse: manager, execution: 'real Worker preview authorization + local R2 + isolated Node HTTP module', privateLinkExcluded: true }; + } + const report = { mode: 'real-local-worker-api', root, ownDevelopmentProject: !!options.selfDevelopment, sourceSnapshot, previewEvidence, workerUrl: operator.workerUrl, organizationId: orgId, projectId, resourceId: resource.resourceId, initial, corrected, + gates: { realSourceApiWorkflow: 'pass', independentlyVerifiedBehavior: 'pass', persistedCorrectionInLaterContext: 'pass', atomicAcceptance: 'pass', freshAgent: 'not_run', cloudRelease: 'not_run', browserOwnerJourney: 'not_run' }, events }; + await writeFile(join(root, 'report.json'), JSON.stringify(report, null, 2)); + // Local-only bootstrap for browser review; never exported as project context or committed. + await writeFile(join(root, 'owner-session.json'), JSON.stringify({ ownerToken, organizationId: orgId, projectId }), { mode: 0o600 }); + return report; +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) void rehearseWorker(process.argv[2]!, process.argv[3]!, { selfDevelopment: process.argv.includes('--self-development'), ownerSessionFile: process.env.G1_OWNER_SESSION_FILE, preview: process.argv.includes('--preview') }).then(r => process.stdout.write(`${JSON.stringify(r, null, 2)}\n`)).catch(e => { process.stderr.write(`${e instanceof Error ? e.stack : String(e)}\n`); process.exitCode = 1; }); diff --git a/apps/runner/test/g1-artifacts-sidecar.test.ts b/apps/runner/test/g1-artifacts-sidecar.test.ts new file mode 100644 index 0000000..5e780f9 --- /dev/null +++ b/apps/runner/test/g1-artifacts-sidecar.test.ts @@ -0,0 +1,26 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { createGit, RUNNER_IDENTITY_ENV } from '../src/git.js'; +import { startArtifactsSidecar } from '../src/g1-artifacts-sidecar.js'; +it('serves real Git smart HTTP with repository-scoped expiring/revocable credentials', async () => { + const root = await mkdtemp(join(tmpdir(), 'g1-real-sidecar-')); + const sidecar = await startArtifactsSidecar({ stateDir: root, key: 'local-rpc-key' }); + try { + const repo = await sidecar.artifacts.createRepo('test-canonical', 'site'); + const seeded = await sidecar.artifacts.seedScheduling('test-canonical', 'site'); + const work = join(root, 'checkout'), git = createGit({ allowHttp: true }); + const env = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.${repo.remote}.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Bearer ${repo.token}` }; + await git.ok(['clone', '-q', repo.remote, work], root, { env }); + expect((await sidecar.artifacts.log('test-canonical', 'site', { ref: 'main' }))[0]?.hash).toBe(seeded.sha); + await writeFile(join(work, 'extra.txt'), 'real source update'); + await git.ok(['add', '.'], work); await git.ok(['commit', '-q', '-m', 'Real edit'], work, { env: RUNNER_IDENTITY_ENV(new Date().toISOString()) }); + await git.ok(['push', '-q', 'origin', 'HEAD:refs/heads/main'], work, { env }); + expect(new TextDecoder().decode(await sidecar.artifacts.readFile('test-canonical', 'site', 'main', 'extra.txt') ?? undefined)).toBe('real source update'); + const tokens = await sidecar.artifacts.listTokens('test-canonical', 'site'); + await sidecar.artifacts.revokeToken('test-canonical', 'site', tokens[0]!.id); + expect((await git.run(['ls-remote', repo.remote], root, { env })).code).not.toBe(0); + expect((await fetch(`${sidecar.url}/rpc`, { method: 'POST', body: '{}' })).status).toBe(403); + } finally { await sidecar.close(); await rm(root, { recursive: true, force: true }); } +}); diff --git a/apps/runner/test/g1-bwrap-commands.test.ts b/apps/runner/test/g1-bwrap-commands.test.ts new file mode 100644 index 0000000..8f43f2f --- /dev/null +++ b/apps/runner/test/g1-bwrap-commands.test.ts @@ -0,0 +1,18 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { localNamespaceCommands } from '../src/g1-bwrap-commands.js'; +import { runIsolated } from '../src/commands.js'; +it('real runner Node entrypoints cannot read controller state or reach host networking', async () => { + const root = await mkdtemp(join(tmpdir(), 'g1-command-boundary-')); + try { + const candidate = join(root, 'candidate'); await mkdir(candidate); + const secret = join(root, 'controller-key'); await writeFile(secret, 'controller must remain isolated'); + const commands = await localNamespaceCommands(join(root, 'bin')); + await writeFile(join(candidate, 'test.mjs'), `import {existsSync,writeFileSync} from 'node:fs'; import {networkInterfaces} from 'node:os'; if (existsSync(${JSON.stringify(secret)}) || Object.values(networkInterfaces()).flat().some(x=>x&&!x.internal)) process.exit(1); writeFileSync('passed.txt','isolated');`); + const result = await runIsolated({ argv: [join(root, 'bin/node'), 'test.mjs'], cwd: candidate, env: { PATH: commands.path!, HOME: '/tmp' }, timeoutMs: 5000 }); + expect(result.exitCode, result.stderr).toBe(0); + expect(commands.allowlist).toEqual([['node', '{path}', '{args*}']]); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/apps/runner/test/g1-local-preview.test.ts b/apps/runner/test/g1-local-preview.test.ts new file mode 100644 index 0000000..0edceb2 --- /dev/null +++ b/apps/runner/test/g1-local-preview.test.ts @@ -0,0 +1,15 @@ +import { readFile } from 'node:fs/promises'; +import { expect, it } from 'vitest'; +import { startLocalPreview } from '../src/g1-local-preview.js'; +it('serves a real API module in namespaces with no candidate-controlled supervisor or network', async () => { + const preview = await startLocalPreview({ key: 'local-preview-auth-key' }); + try { + const module = (await readFile(new URL('../../../fixtures/scheduling/worker.mjs', import.meta.url))).toString('base64'); + const response = await fetch(`${preview.url}/serve`, { method: 'POST', headers: { authorization: 'Bearer local-preview-auth-key', 'content-type': 'application/json' }, body: JSON.stringify({ module, path: '/reschedule', method: 'POST', body: JSON.stringify({ actor: 'manager', appointment: '2026-10-17T13:00:00.000Z', reason: 'Preview emergency' }) }) }); + expect(response.status).toBe(200); + const result = await response.json() as { status: number; body: string }; + expect(result.status).toBe(200); + expect(JSON.parse(Buffer.from(result.body,'base64').toString('utf8'))).toMatchObject({ allowed: true, override: { actor: 'manager', reason: 'Preview emergency' } }); + expect((await fetch(`${preview.url}/serve`, { method: 'POST', body: '{}' })).status).toBe(403); + } finally { await preview.close(); } +}); diff --git a/apps/runner/test/g1-local-verifier.test.ts b/apps/runner/test/g1-local-verifier.test.ts new file mode 100644 index 0000000..948efd0 --- /dev/null +++ b/apps/runner/test/g1-local-verifier.test.ts @@ -0,0 +1,31 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { verifySchedulingCandidate } from '../src/g1-local-verifier.js'; +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); +async function candidate(source: string) { const d = await mkdtemp(join(tmpdir(), 'commonforge-g1-test-')); dirs.push(d); await writeFile(join(d, 'worker.mjs'), source); return d; } +const fixture = new URL('../../../fixtures/scheduling/worker.mjs', import.meta.url); + +describe('trusted real scheduling verifier', () => { + it('executes HTTP behavior across the exact cutoff, travel and retained manager reason', async () => { + const r = await verifySchedulingCandidate(await candidate(await readFile(fixture, 'utf8'))); + expect(r.results).toHaveLength(9); + expect(r.results.every((r) => r.result === 'pass')).toBe(true); + expect(r).toMatchObject({ network: 'none', controlPlaneMounted: false }); + }); + it('candidate-owned tests cannot weaken the independently pinned harness', async () => { + const d = await candidate('export default { fetch() { return Response.json({allowed:true,override:null}); } };'); + await writeFile(join(d, 'test.mjs'), 'process.exit(0)'); + const r = await verifySchedulingCandidate(d); + expect(r.results.some((r) => r.result === 'fail')).toBe(true); + }); + it('candidate cannot read a controlling-instance secret or reach its network', async () => { + const secretDir = await mkdtemp(join(tmpdir(), 'commonforge-control-')); dirs.push(secretDir); + const secret = join(secretDir, 'supervisor-key'); await writeFile(secret, 'must-not-be-readable'); + const source = `import {existsSync} from 'node:fs';\nimport {networkInterfaces} from 'node:os';\nconst isolated = !existsSync(${JSON.stringify(secret)}) && !Object.values(networkInterfaces()).flat().some(x => x && !x.internal);\n${await readFile(fixture, 'utf8')}`.replace('return Response.json(reschedule(await request.json()))', "return Response.json(isolated ? reschedule(await request.json()) : {allowed:false,code:'isolation_broken'})"); + const r = await verifySchedulingCandidate(await candidate(source)); + expect(r.results.every((r) => r.result === 'pass')).toBe(true); + }); +}); diff --git a/apps/runner/test/g1-real-local.test.ts b/apps/runner/test/g1-real-local.test.ts new file mode 100644 index 0000000..16d13f6 --- /dev/null +++ b/apps/runner/test/g1-real-local.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { runRealLocal } from '../src/g1-real-local.js'; +describe('G1 real local source/runner/preview rehearsal', () => { + it('uses real local Git, a signed Node runner, disk blobs, HTTP preview, and isolated trusted scenarios', async () => { + const local = await runRealLocal(); + try { + expect(local.report.gates).toMatchObject({ realGit: 'pass', signedNodeRunner: 'pass', localBlobStore: 'pass', httpPreview: 'pass', trustedIsolatedScenarios: 'pass', workerWorkflow: 'not_run', cloudRelease: 'not_run' }); + const capabilities = await (await fetch(`${local.report.bridgeUrl}/capabilities`)).json() as { mode: string }; + expect(capabilities.mode).toBe('real-local'); + expect((await fetch(`${local.report.bridgeUrl}/verify`, { method: 'POST', body: '{}' })).status).toBe(400); + expect((await fetch(`${local.report.bridgeUrl}/capabilities`, { headers: { origin: 'https://hostile.example' } })).status).toBe(403); + expect(local.report.verification.evidence.every(e => e.provenance === 'runner-verified')).toBe(true); + } finally { await local.close(); } + }); +}); diff --git a/apps/web/e2e/local-stack.spec.ts b/apps/web/e2e/local-stack.spec.ts index 44936da..8017993 100644 --- a/apps/web/e2e/local-stack.spec.ts +++ b/apps/web/e2e/local-stack.spec.ts @@ -739,9 +739,9 @@ test("Getting started on a fresh organization: Git import, release example, firs await panel.getByRole("button", { name: "Publish this version" }).click(); await panel.getByRole("button", { name: "Approve publishing this release" }).click({ timeout: 30_000 }); await panel.getByRole("button", { name: "Publish to production" }).click(); - await expect(panel.locator(".badge", { hasText: /^Live$/ })).toBeVisible({ timeout: 45_000 }); - // the version list follows without a reload - await expect(page.locator("li.version-live").locator(".badge", { hasText: /^Live$/ })).toBeVisible({ timeout: 20_000 }); + // First publication intentionally removes the publish panel once it is live + // (Versions.tsx publishTarget). Wait for the durable timeline state, not that transient panel. + await expect(page.locator("li.version-live").locator(".badge", { hasText: /^Live$/ })).toBeVisible({ timeout: 45_000 }); // 4. staging: the Staging example; production is live, so staging starts at once (no waiting) await overview(); diff --git a/apps/web/e2e/native.spec.ts b/apps/web/e2e/native.spec.ts new file mode 100644 index 0000000..b8c1984 --- /dev/null +++ b/apps/web/e2e/native.spec.ts @@ -0,0 +1,126 @@ +/** Actual browser + real Worker API. No intercepted requests or fixture provider results. */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { expect, test, type Page } from '@playwright/test'; + +const OWNER = process.env.COMMONFORGE_NATIVE_OWNER ?? 'owner@agency.e2e.test'; +const LOG = process.env.COMMONFORGE_NATIVE_LOG ?? '/tmp/g1-real-worker.log'; +async function signedIn(page: Page) { + if (process.env.COMMONFORGE_NATIVE_SESSION_FILE) { + const session = JSON.parse(readFileSync(process.env.COMMONFORGE_NATIVE_SESSION_FILE, 'utf8')) as { ownerToken: string }; + await page.setExtraHTTPHeaders({ authorization: `Bearer ${session.ownerToken}` }); + await page.goto('/'); + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); + return; + } + const start = readFileSync(LOG, 'utf8').length; + await page.goto('/signin'); + await page.getByLabel('Work email').fill(OWNER); + await page.getByRole('button', { name: 'Email me a sign-in link' }).click(); + await expect(page.getByRole('status')).toContainText('Check your email'); + let link: string | undefined; + await expect.poll(() => { + const lines = readFileSync(LOG, 'utf8').slice(start).split('\n'); + link = lines.filter(line => line.includes(`magic link for ${OWNER}: `)).at(-1)?.match(/https?:\/\/[^\s]+/)?.[0]; + return !!link; + }).toBe(true); + // Secret link is intentionally neither logged nor persisted in a test artifact. + await page.goto(link!); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); +} +async function api(page: Page, path: string, body: unknown) { + return page.evaluate(async ({ path, body }) => { + const r = await fetch('/api' + path, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const data = await r.json(); + if (!r.ok) throw new Error(`API ${r.status}: ${data.error?.message ?? 'operation failed'}`); + return data; + }, { path, body }); +} + +test('owner preserves a correction and sees honest outcomes and review gaps using the real API', async ({ page }, info) => { + await signedIn(page); + const org = await api(page, '/orgs', { name: `Native browser rehearsal ${Date.now()}` }); + const project = await api(page, `/orgs/${org.orgId}/projects`, { name: 'Durable understanding browser rehearsal' }); + const root = `/o/${org.orgId}/p/${project.projectId}`; + await page.goto(root + '/knowledge'); + await page.getByRole('button', { name: 'Enable project understanding' }).click(); + await expect(page.getByRole('heading', { name: 'Knowledge', exact: true })).toBeVisible(); + const initial = 'Customers may reschedule until 24 hours before their appointment.'; + const correction = 'Managers may override the 24-hour cutoff only with a retained reason.'; + await page.getByLabel('Requirement or understanding').fill(initial); + await page.getByRole('textbox', { name: 'Rationale', exact: true }).fill('Predictable dispatch with recorded owner intent.'); + await page.getByLabel('Source or provenance').fill('Synthetic owner requirement in the real API browser rehearsal.'); + await page.getByRole('button', { name: 'Save draft', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Confirm rule' })).toBeVisible(); + await page.getByRole('button', { name: 'Confirm rule' }).click(); + await expect(page.getByText('rule · revision 2 · Confirmed', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Correct this record' }).click(); + await page.getByLabel('Requirement or understanding').fill(correction); + await page.getByRole('textbox', { name: 'Rationale', exact: true }).fill('Manager exception must survive subsequent agent sessions.'); + await page.getByRole('button', { name: 'Save draft', exact: true }).click(); + await page.getByRole('button', { name: 'Confirm rule' }).click(); + await expect(page.getByText('rule · revision 4 · Confirmed', { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByText(correction, { exact: true }).first()).toBeVisible(); + await page.getByLabel('Title', { exact: true }).fill('Manager override retains its reason'); + await page.getByLabel('Given', { exact: true }).fill('An appointment is less than 24 hours away and a manager is authorized'); + await page.getByLabel('When', { exact: true }).fill('The manager overrides the cutoff with a reason'); + await page.getByLabel('Then', { exact: true }).fill('The request succeeds and retains the reason'); + await page.getByRole('button', { name: 'Save scenario draft' }).click(); + await page.getByRole('button', { name: 'Confirm scenario' }).click(); + await expect(page.getByRole('heading', { name: 'Manager override retains its reason · revision 2 · Confirmed' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('knowledge-confirmed-correction.png'), fullPage: true }); + await page.goto(root + '/outcomes'); + await page.getByLabel('Desired improvement').fill('A fresh agent preserves the manager exception'); + await page.getByLabel('Success criteria (one per line)').fill('The manager reason remains mandatory\nUnauthorized overrides remain denied'); + await page.getByRole('button', { name: 'Create outcome' }).click(); + await expect(page.getByRole('heading', { name: 'A fresh agent preserves the manager exception' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('outcome-open.png'), fullPage: true }); + await page.goto(root + '/review'); + await expect(page.getByText('No proposals submitted.', { exact: false })).toBeVisible(); + await expect(page.getByText('Health unknown.', { exact: false })).toBeVisible(); + await expect(page.getByText('Requirements recorded; implementation verification outstanding')).toHaveCount(3); + await page.screenshot({ path: info.outputPath('review-outstanding-unknown.png'), fullPage: true }); + await info.attach('real-api-journey', { contentType: 'application/json', body: JSON.stringify({ execution: 'real-browser-real-worker-api', orgId: org.orgId, projectId: project.projectId, correction, screenshots: ['knowledge-confirmed-correction.png', 'outcome-open.png', 'review-outstanding-unknown.png'], limitations: 'No source proposal or release is claimed by this UI correction test.' }) }); +}); + +test('owner reviews real runner evidence and accepts the exact source proposal', async ({ page }, info) => { + test.skip(!process.env.COMMONFORGE_NATIVE_REVIEW_REPORT, 'Requires an actual unaccepted verified source proposal prepared through the real-local workflow.'); + const report = JSON.parse(readFileSync(process.env.COMMONFORGE_NATIVE_REVIEW_REPORT!, 'utf8')) as { organizationId: string; projectId: string; proposalId: string; envelopeDigest: string; scenarioCount: number }; + await signedIn(page); + await page.goto(`/o/${report.organizationId}/p/${report.projectId}/review`); + const proposal = page.locator(`article[data-proposal-id="${report.proposalId}"]`); + await expect(proposal).toBeVisible(); + await expect(proposal.getByText('Verified runner execution', { exact: false })).toHaveCount(report.scenarioCount); + await expect(proposal.getByText('pass · Verified runner execution', { exact: false })).toHaveCount(report.scenarioCount); + if (process.env.COMMONFORGE_NATIVE_REQUIRE_PREVIEW === '1') { + await proposal.getByRole('button', { name: 'Create exact-source preview' }).click(); + const link = proposal.getByRole('link', { name: 'Open reviewed preview' }).first(); + await expect(link).toBeVisible({ timeout: 60000 }); + const opened = page.waitForEvent('popup'); + await link.click(); + const preview = await opened; + await expect(preview.getByRole('heading', { name: 'Synthetic scheduling' })).toBeVisible(); + await preview.screenshot({ path: info.outputPath('real-reviewed-preview.png') }); + // Preview URLs contain short-lived capability tokens and are never included in reports. + const health = await preview.request.get(preview.url() + 'api/health'); + expect(health.ok()).toBe(true); expect((await health.json()).ruleVersion).toBe(2); + const manager = await preview.request.post(preview.url() + 'api/reschedule', {data:{actor:'manager',appointment:'2026-10-17T13:00:00.000Z',reason:'Owner preview observation'}}); + expect(manager.ok()).toBe(true); expect((await manager.json()).override.reason).toBe('Owner preview observation'); + await preview.close(); + } + const alreadyAccepted = await proposal.getByText('Accepted', { exact: true }).isVisible(); + if (!alreadyAccepted) { + await expect(proposal.getByRole('button', { name: 'Approve exact change' })).toBeDisabled(); + await proposal.getByLabel('Review explanation').fill('Owner reviewed the exact self-development change and real independently pinned runner evidence; the manager exception retains its reason.'); + await page.screenshot({ path: info.outputPath('source-review-real-evidence.png'), fullPage: true }); + await proposal.getByRole('button', { name: 'Approve exact change' }).click(); + await expect(proposal.getByRole('button', { name: 'Accept project version' })).toBeVisible(); + await proposal.getByRole('button', { name: 'Accept project version' }).click(); + } + await expect(proposal.getByText('Accepted', { exact: true })).toBeVisible(); + await expect(page.getByText('Accepted with applicable behavioral evidence', { exact: true }).first()).toBeVisible(); + await page.screenshot({ path: info.outputPath('source-accepted-real-evidence.png'), fullPage: true }); + writeFileSync(info.outputPath('source-acceptance.json'), JSON.stringify({ execution:'real-browser-real-worker-real-git-real-runner', organizationId:report.organizationId, projectId:report.projectId, proposalId:report.proposalId, envelopeDigest:report.envelopeDigest, scenarioCount:report.scenarioCount, acceptedInBrowser:!alreadyAccepted, inspectedExistingAcceptance:alreadyAccepted,releaseClaimed:false },null,2)); + await info.attach('real-source-acceptance', { contentType: 'application/json', body: JSON.stringify({ execution: 'real-browser-real-worker-real-git-real-runner', ...report, acceptedInBrowser: !alreadyAccepted, inspectedExistingAcceptance: alreadyAccepted, releaseClaimed: false }) }); +}); diff --git a/apps/web/playwright.native.config.ts b/apps/web/playwright.native.config.ts new file mode 100644 index 0000000..f2fb6d0 --- /dev/null +++ b/apps/web/playwright.native.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** External REAL Worker started by scripts/dev-real-worker.sh; no fake provider routes. */ +export default defineConfig({ + testDir: './e2e', testMatch: ['native.spec.ts'], workers: 1, fullyParallel: false, + reporter: 'list', outputDir: 'test-results/native', timeout: 120000, + expect: { timeout: 20000 }, + use: { baseURL: process.env.COMMONFORGE_NATIVE_URL ?? 'http://127.0.0.1:8899', ...devices['Desktop Chrome'], trace: 'off' }, +}); diff --git a/apps/web/src/api/native.ts b/apps/web/src/api/native.ts new file mode 100644 index 0000000..6d1f8d5 --- /dev/null +++ b/apps/web/src/api/native.ts @@ -0,0 +1,31 @@ +import { ProjectStateSchema, type ProjectState } from '@commonforge-org/core'; +import { ApiError } from './client.js'; + +async function request(org: string, pid: string, suffix = '', body?: unknown): Promise> { + const res = await fetch(`/api/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(pid)}/native${suffix}`, { + method: body === undefined ? 'GET' : 'POST', credentials: 'same-origin', + headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const data = await res.json() as Record; + if (!res.ok) { + const error = data.error && typeof data.error === 'object' ? data.error as Record : data; + throw new ApiError(res.status, String(error.code ?? 'native_request_failed'), String(error.message ?? 'The operation could not be completed.')); + } + return data; +} +export async function loadNativeProject(org: string, pid: string): Promise { + const data = await request(org, pid); + if (data.protocolVersion !== 1) throw new ApiError(409, 'unsupported_version', 'This server does not support project context version 1.'); + return data.state === null ? null : ProjectStateSchema.parse(data.state); +} +export async function enableNativeProject(org: string, pid: string): Promise { + await request(org, pid, '/enable', { idempotencyKey: crypto.randomUUID() }); +} +export async function nativeCommand(org: string, pid: string, generation: number, body: Record): Promise { + if (body.type === 'requestVerification') { + await request(org, pid, `/proposals/${encodeURIComponent(String(body.proposalId))}/verify`, { idempotencyKey: crypto.randomUUID() }); + return; + } + await request(org, pid, '/commands', { ...body, expectedGeneration: generation, idempotencyKey: crypto.randomUUID() }); +} diff --git a/apps/web/src/lib/router.ts b/apps/web/src/lib/router.ts index 56a0172..5260a02 100644 --- a/apps/web/src/lib/router.ts +++ b/apps/web/src/lib/router.ts @@ -2,7 +2,7 @@ import { useSyncExternalStore } from "react"; /** `tasks`: the Tasks board, shown only for staging-mode projects (staging phase 3). */ -export type ProjectTab = "overview" | "files" | "changes" | "versions" | "activity" | "tasks"; +export type ProjectTab = "overview" | "files" | "changes" | "versions" | "activity" | "tasks" | "outcomes" | "knowledge" | "review"; export type Route = | { name: "signin" } @@ -24,6 +24,9 @@ export type Route = | { name: "project"; org: string; pid: string; tab: "changes" } | { name: "project"; org: string; pid: string; tab: "overview" } | { name: "project"; org: string; pid: string; tab: "activity" } + | { name: "project"; org: string; pid: string; tab: "outcomes" } + | { name: "project"; org: string; pid: string; tab: "knowledge" } + | { name: "project"; org: string; pid: string; tab: "review" } /** the Tasks board (staging-mode projects): `/o/:org/p/:pid/tasks` */ | { name: "project"; org: string; pid: string; tab: "tasks" } | { name: "project"; org: string; pid: string; tab: "versions"; mount?: string; /** an accepted version id: open its restore sheet */ version?: string } @@ -63,6 +66,7 @@ export function parseRoute(pathname: string, search: string): Route { const intakeId = q.get("intake") ?? undefined; return { name: "intake", org, pid, ...(intakeId ? { intakeId } : {}) }; } + if ((tab === "outcomes" || tab === "knowledge" || tab === "review") && parts.length === 5) return { name: "project", org, pid, tab }; if (tab === "changes" && parts.length === 5) return { name: "project", org, pid, tab: "changes" }; if (tab === "overview" && parts.length === 5) return { name: "project", org, pid, tab: "overview" }; if (tab === "activity" && parts.length === 5) return { name: "project", org, pid, tab: "activity" }; diff --git a/apps/web/src/screens/AgentHandoff.tsx b/apps/web/src/screens/AgentHandoff.tsx index ef59956..e567d78 100644 --- a/apps/web/src/screens/AgentHandoff.tsx +++ b/apps/web/src/screens/AgentHandoff.tsx @@ -29,6 +29,7 @@ import { loginCommand, mayRunAgent, submitCommand, + shellWord, taskFolders, type HandoffTask, } from "../lib/handoff.js"; @@ -63,7 +64,7 @@ export function useHandoffContext(role: Role, staging: boolean): HandoffContext } export function handoffTask(task: Pick & Partial>): HandoffTask { - return { taskId: task.taskId, title: task.title, request: task.request ?? "", ...taskFolders(task), ...(task.lease ? { lease: task.lease } : {}) }; + return { ...((task as { projectProtocolVersion?: number }).projectProtocolVersion ? { projectProtocolVersion: (task as { projectProtocolVersion?: number }).projectProtocolVersion } : {}), taskId: task.taskId, title: task.title, request: task.request ?? "", ...taskFolders(task), ...(task.lease ? { lease: task.lease } : {}) }; } /** A command (or several lines) with its copy button. */ @@ -152,6 +153,9 @@ function CopyPrompt({ task, ctx }: { task: HandoffTask; ctx: HandoffContext }) { } export function AgentHandoff({ task, ctx, ownTask = true, onDone, intro }: { task: HandoffTask; ctx: HandoffContext; ownTask?: boolean; onDone: () => void; intro?: string }) { + const projectProtocol = (task as HandoffTask & { projectProtocolVersion?: number }).projectProtocolVersion; + if (projectProtocol !== undefined && projectProtocol !== 1) return

This project requires an unsupported agent protocol. Update CommonForge before assigning an agent.

; + if (projectProtocol === 1) return ; const dir = checkoutDir(task.title); const canRun = mayRunAgent(ctx.role, { staging: ctx.staging, ownTask }); const client = !isDeveloperRole(ctx.role); @@ -282,3 +286,24 @@ export function TaskHandoffLoader({ taskId, ctx, load, ownTask = true, onDone }: if (!task) return ; return ; } + + +/** Native projects exchange one-use task authority; never create a broad CLI session. */ +function NativeAgentHandoff({ task, ctx, onDone }: { task: HandoffTask; ctx: HandoffContext; onDone: () => void }) { + const [agentId, setAgentId] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const dir = checkoutDir(task.title); + const copy = () => { + setBusy(true); setError(null); + const prompt = fetch(`/api/tasks/${encodeURIComponent(task.taskId)}/native/handoff`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agentId }) }).then(async res => { + const body = await res.json() as { code?: string; error?: { code: string; message: string } }; + if (!res.ok || !body.code) throw new ApiError(res.status, body.error?.code ?? 'handoff_failed', body.error?.message ?? 'Task handoff could not be issued.'); + const safe = (value: string) => { const word = shellWord(value); if (!word) throw new Error('The handoff contains an unsupported command value.'); return word; }; + return `Work on CommonForge task ${task.taskId}: ${task.title}\n${task.request}\n\nUse only the task-scoped handoff below. Build cforge from the public CommonForge source if unavailable.\n${CLI_SOURCE_INSTALL}\n\ncforge handoff --api-url ${safe(ctx.apiUrl)} --code ${safe(body.code)}\ncforge checkout ${safe(task.taskId)} ${safe(dir)}\ncforge context ${safe(task.taskId)} --out ${safe(dir)}\n\nRead ${dir}/.commonforge/context.md and context.json before editing. Preserve confirmed rules and their corrections. Do not edit .commonforge files. Implement only the assigned task, then run applicable checks and cforge submit from the checkout. Propose corrections through structured native commands; human confirmation, acceptance and release are separate authority. No provider credentials or broad user login are required.`; + }); + void copyLater(prompt).then(() => setCopied(true), e => setError(e)).finally(() => setBusy(false)); + }; + return

Task-scoped agent handoff

{task.title}

The agent receives this task's source access and pinned project understanding. It cannot confirm rules, accept changes or deploy.

{copied ? 'Copied. This handoff code works once within 15 minutes. Task authority expires after one hour unless renewed.' : 'The copied prompt includes a one-use task code. It does not sign the agent in as you.'}

{error ?

{errorMessage(error, true)}

: null}
; +} diff --git a/apps/web/src/screens/NativeReview.tsx b/apps/web/src/screens/NativeReview.tsx new file mode 100644 index 0000000..2f8c1f2 --- /dev/null +++ b/apps/web/src/screens/NativeReview.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { currentConfirmed, observationHealth, isRevisionId, revisionCommitSha, type ProjectProposal, type ProjectState } from '@commonforge-org/core'; +import { useAction, useApp, useLoad } from '../lib/hooks.js'; +import { projectHref } from '../lib/router.js'; +import { ErrorNote, Link } from '../ui.js'; +import type { PreviewView } from '../api/types.js'; +import { NativeProject } from './ProjectKnowledge.js'; + +export function NativeReview() { + return {(state, command, busy) => }; +} +export function Review({ state, command, busy }: { state: ProjectState; command: (body: Record) => Promise; busy: boolean }) { + return <>

Behavioral change review

Review the exact requirements, implementation, and observed behavior. Acceptance records a project version; release is a separate operation.

{state.proposals.length === 0 ?

No proposals submitted. An assigned agent submits a change using its pinned project context.

: [...state.proposals].reverse().map(p => )}

Accepted project history

{[...state.manifests].reverse().map(m =>

Project version {m.sequence}

{m.behavior === 'outstanding' ? 'Requirements recorded; implementation verification outstanding' : m.behavior === 'verified' ? 'Accepted with applicable behavioral evidence' : 'Historical source; behavior unverified'}

{m.createdAt}

Exact accepted subject

Manifest: {m.hash}

Knowledge: {m.knowledgeHead}

{m.sourceHeads.map(h =>

{h.resourceId}: {h.revisionId}

)}
)}

Release observations

{state.observations.length === 0 ?

Health unknown. No bounded release observation has been recorded.

: state.observations.map(o =>

{o.environment}: {observationHealth(o, new Date().toISOString())}

{o.summary}

Release {o.releaseId} · observed {o.observedAt} · fresh until {o.freshUntil}

Manifest {o.manifestHash}

)}
; +} +function Proposal({ proposal: p, state, command, busy }: { proposal: ProjectProposal; state: ProjectState; command: (body: Record) => Promise; busy: boolean }) { + const { me, api } = useApp(); + const previews = useLoad(() => p.envelope.innerProposalId ? api.listPreviews(state.organizationId, p.envelope.innerProposalId) : Promise.resolve([]), `${p.id}/previews`, 15000); + const requestPreview = useAction(async () => { if (p.envelope.innerProposalId) { await api.requestPreview(state.organizationId, p.envelope.innerProposalId); previews.reload(); } }); + const [explanation, setExplanation] = useState(''); + const e = p.envelope; + const stale = e.expectedKnowledgeHead !== state.knowledgeHead; + const accepted = state.manifests.some(m => m.envelopeDigest === p.digest); + const decision = [...state.decisions].reverse().find(d => d.envelopeDigest === p.digest && d.action === 'approve'); + const evidence = state.evidence.filter(ev => ev.envelopeDigest === p.digest); + const purpose = e.outcome ? state.outcomes.find(o => o.id === e.outcome!.id && o.revision === e.outcome!.revision)?.description : null; + return

{purpose ?? (e.innerProposalId ? 'Source change' : 'Knowledge change')}

{new Date(p.createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC

{accepted ? 'Accepted' : stale ? 'Requirements changed: refresh and verify this change again.' : 'Awaiting review'}

Submitted by {p.author} · {p.createdAt}

{e.outcome ?

Outcome: {state.outcomes.find(o => o.id === e.outcome!.id && o.revision === e.outcome!.revision)?.description ?? 'Referenced outcome is unavailable'}

: null}

{e.innerProposalId ? 'Includes source changes' : 'Knowledge-only proposal'} · {e.requiredScenarios.length} required scenarios

Working preview

{e.innerProposalId ? : null}{!e.innerProposalId ?

Knowledge-only change; no new preview.

: previews.data?.length ? previews.data.map(preview =>

{preview.state === 'ready' && preview.proposalHash === e.innerProposalHash && previewMatchesProjectSource(preview, e.sourceHeads) && preview.url && /^https?:\/\//.test(preview.url) ? Open reviewed preview : `Preview ${preview.state}${preview.proposalHash !== e.innerProposalHash || !previewMatchesProjectSource(preview, e.sourceHeads) ? ' (source subject does not match this review)' : ''}`}{preview.expiresAt ? ` · expires ${preview.expiresAt}` : ''}

) :

No ready preview is available for this change.

}
{accepted ? 'Inspect accepted behavior evidence' : 'Requirements and observations'}{!accepted && e.requiredScenarios.some(s => s.method === 'automated') ? : null}{e.requiredScenarios.length === 0 ?

No behavioral scenarios required by the current policy.

: e.requiredScenarios.map(s => { + const definition = state.scenarios.find(r => r.id === s.id && r.revision === s.revision); + const results = evidence.filter(ev => ev.scenarioId === s.id && ev.scenarioRevision === s.revision); + return
{definition?.title ?? s.id} · revision {s.revision} · {s.method}{definition ? <>

Given {definition.given}; when {definition.when}; then {definition.then}

{definition.ruleRefs.map(ref =>

Rule: {state.knowledge.find(k => k.id === ref.id && k.revision === ref.revision)?.text ?? 'Referenced rule unavailable'}

)} : null}{results.length === 0 ?

Not run: evidence missing

: results.map(ev =>

{ev.result} · {ev.provenance === 'runner-verified' ? 'Verified runner execution' : ev.provenance === 'human-attested' ? 'Human observation' : 'Imported history, untrusted locally'} · {ev.actor} · {ev.completedAt}
{ev.summary}{stale ? ' This check ran before the requirements changed.' : ''}

)}{s.method === 'manual' && !accepted && !stale ? : null}
; + })}
Exact review subject and provenance

Proposal {p.id}

Envelope {p.digest}

Build {e.buildDigest}

Runtime recipe {e.runtimeRecipeDigest}

Knowledge {e.expectedKnowledgeHead}

Inner proposal {e.innerProposalHash ?? 'none'}

{e.effects.length ?
    {e.effects.map(effect =>
  • {effect}
  • )}
:

No external effects declared.

}
{!accepted ? <>