Compare commits

...
Author SHA1 Message Date
dev-guard 9b7b90be41 Test environment lifecycle routes with an absent owned-create fixture 2026-10-30 04:55:11 +00:00
dev-guard a03c774dec test: check every exposed activity verb on the confined listener 2026-10-30 04:48:39 +00:00
dev-guard 6e717b1567 Merge grant-only activity ingress dependency into environment correction 2026-10-30 04:47:33 +00:00
dev-guard 15054fbdf9 test: use runtime schema for artifact attribution boundary 2026-10-30 04:31:19 +00:00
dev-guard 7561101de6 fix: confine guest ingress and reconcile before environment writes 2026-10-30 04:17:49 +00:00
dev-guard ad31f5d250 test: exercise corrected startup and ingestion regression controls 2026-10-30 04:14:06 +00:00
dev-driver 238667c3e8 fix(daemon): re-check a grant after its post's body, and add a guest router
Revocation was checked before the route awaited the request body. A
revoke, or a reissue for the same generation, during that wait was never
seen, so the post still recorded activity for the seat. The grant is now
read again after the body arrives, in the same synchronous step that
checks seat and generation, with nothing awaited before the write. The
Claude identity branch awaits a process read later on; a grant's post is
decided again after that wait too.

A guest must not reach the daemon's main port: its other routes don't
check the grant. guestActivityRoutes serves only POST /hooks and accepts
only a grant, sharing the same handler and store, for a guest-only
listener to mount. It has no install-wide token, events, parked or Pi
usage routes. The main routes are unchanged.

The runner chapter's activity section now states the guest's real
boundary: the guest-only listener, never the main port.
2026-10-30 04:07:41 +00:00
dev-driver b7a836ec97 test(daemon): a grant revoked during its post's body, and a guest router
Two more sets of controls for the seat activity grant:
- a post whose body is held after the route authenticated it, while its
  grant is revoked or reissued for the same generation, then released:
  refused as revoked, with nothing recorded and the seat still unknown;
- a guest router, mounted as a guest-only listener mounts it: a grant's
  post for its own seat is taken; the install-wide token is refused;
  wrong-seat, stale and revoked grants are still refused; and nothing
  else is served (events, parked, Pi usage, other methods, other APIs).
2026-10-30 04:07:40 +00:00
dev-guard fb85b67911 fix: narrow confirmed binding and task after artifact refusal 2026-10-30 03:34:57 +00:00
dev-guard de3549274c fix: namespace provider create effects independently of caller operation names 2026-10-30 03:31:53 +00:00
dev-guard 248633703f fix: identify the contained runtime to the existing activity relay 2026-10-30 03:29:01 +00:00
dev-guard cb8f5637c7 feat: bind OpenShell workers to validated context and verified artifact return 2026-10-30 03:27:23 +00:00
dev-guard 7da94b6985 Merge seat activity ingress dependency for OpenShell projection 2026-10-30 03:26:02 +00:00
dev-guard b7a1234324 test(environment): cover artifact publication and receipt recovery 2026-10-30 02:49:43 +00:00
dev-driver bdcd0992ea feat(daemon): accept a seat activity grant on the hook ingress
The hook ingress accepted only the install-wide token. It now resolves
the principal first: the install-wide token keeps its behaviour for every
seat, and any other token is looked up as a seat activity grant. Only
then is the seat and generation a post claims accepted.

A grant's post is refused, and records nothing, when:
- the grant was revoked (401 activity_grant_revoked);
- it names another seat by name or node (403 activity_grant_wrong_seat);
- it claims a generation the grant wasn't issued for, or the grant's
  occupant was replaced (409 activity_grant_stale);
- the grant's launch hasn't registered its occupant yet
  (409 activity_grant_pending).

Otherwise the post is bound to the grant's seat and generation, so the
generation is authenticated. A refused post records nothing, so a missing
event leaves the seat unknown, never idle. The Pi usage routes keep the
install-wide token only.

The runner chapter's activity section describes the grant.
2026-10-30 02:46:21 +00:00
dev-driver 5cb44e3a5e test(daemon): a seat activity grant on the hook ingress
Through the real hook route, store, occupant ledger and activity oracle,
with two runner seats:
- the grant's own seat: its posts decide the seat's activity, and the
  seat and generation come from the grant when left out;
- the install-wide token: unchanged for every seat;
- another seat's name or node, or a seat that doesn't exist: refused;
- a claimed generation the grant wasn't issued for, or a replaced
  occupant: refused as stale;
- a launch that hasn't registered its occupant: refused as pending,
  then accepted once it has;
- a revoked grant, including one replaced by a reissue: refused;
- an unknown grant, another token or none: unauthorized, as today;
- malformed bodies from a grant holder: refused;
- every refusal records nothing, so the seat stays unknown, never idle.
2026-10-30 02:46:21 +00:00
dev-driver 3eac761bc8 feat(daemon): add a seat activity grant store
A workload outside the host's own hook setup, such as a guest in another
environment or a runner inside one, needs to post activity for its seat.
The only credential today is the install-wide hook token, which speaks
for every seat, so it can't go into a guest.

A seat activity grant is the scoped replacement: one seat (node) and one
launch generation. This adds its store (migration 143) and the values a
guest receives:
- issue(): one active grant per seat. Issuing again revokes the earlier
  grant ("reissued"), so a lost response is reconciled by issuing again.
  The generation is the one the launch reserved, which may not be
  registered yet. The token is returned once; only its sha256 is stored.
- revoke() by grant or by seat, idempotent; a revoked grant keeps its row.
- grantGeneration(): current, stale (the occupant was replaced) or
  pending (the launch hasn't registered it yet), from the occupant ledger.
- guestActivityEnv(): the only activity values a guest receives. The
  grant travels as OPENRIG_ACTIVITY_HOOK_TOKEN, the name runners already
  read, with an http(s) origin the guest can reach.

Nothing reads a grant yet; the hook ingress does in a following commit.
2026-10-30 02:46:21 +00:00
dev-guard f8e062fe15 test(environment): cover binding reconciliation and verified projection 2026-10-30 02:42:58 +00:00
48 changed files with 2825 additions and 27 deletions

No files matched your search

@@ -0,0 +1,5 @@
# OpenShell reference driver
Run `rig environment setup --help` to install the maintained OpenShell 0.1.6 / Claude Code reference from the packaged daemon assets. Its README, image recipe, endpoint-specific policy, example RigSpec/AgentSpecs and versioned driver schema are installed together. Setup prepares files and an ordinary scoped work grant; it does not create compute or launch a provider.
This first cut supports explicit owned create or exact attached binding, required destination projection and verified JSON artifact return through authenticated MCP work tools. Native stop, compute deletion, checkpoint and exact native restore remain unsupported. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
+16
View File
@@ -122,6 +122,22 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
- **The generation attributes a post; it isn't authentication.**
- A post carrying an earlier occupant's generation never counts for the live seat.
- The hook token is one local token, so any process holding it can post for any seat.
- **A seat activity grant** is what a runner in another environment, such as a sandbox guest, receives instead of the
local token. It arrives in the same `OPENRIG_ACTIVITY_HOOK_TOKEN`, with an `OPENRIG_URL` the guest can reach, and it
speaks only for its own seat and launch generation:
- with a grant, the generation is authenticated, and you may leave out `nodeId` and `generation`;
- a post for another seat is refused with `403 activity_grant_wrong_seat`;
- a post claiming another generation, or from an occupant that has since been replaced, is refused with
`409 activity_grant_stale`;
- a post before the launch has registered its occupant is refused with `409 activity_grant_pending`;
- a post with a revoked grant is refused with `401 activity_grant_revoked`, including a post whose grant was revoked
while its body was arriving.
A refused post records nothing, so the seat's activity stays as it was, or unknown.
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
serves this one route and accepts only a grant. It must never reach the daemon's main port. The grant scopes activity
posts only, and the main port's other routes don't check it.
## 5. Input: one submitted message is one prompt
+1 -1
View File
@@ -8,7 +8,7 @@ const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function envCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("env")
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
.description("Inspect and control rig services; for execution placement use rig environment")
.addHelpText("after", `
Examples:
rig env status secrets-manager
+154
View File
@@ -0,0 +1,154 @@
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { Command } from "commander";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { DaemonClient } from "../client.js";
import { getDaemonPath, getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { getOpenRigHome } from "../openrig-compat.js";
import { addGrant, grantFor, mcpTokenFilePath, readGrants } from "../mcp-http.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export interface EnvironmentCommandDeps extends StatusDeps { home?: string; assets?: string }
interface WorkConnection { version: 1; mcpUrl: string; seat: string; rig: string; grantName: string; artifactRoot: string; token: string }
const managedId = (id: string): string => {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(id)) throw new Error("A short binding identifier is required");
return id;
};
function preserveWrite(file: string, data: string | Buffer): void {
mkdirSync(resolve(file, ".."), { recursive: true, mode: 0o700 });
if (existsSync(file) || (() => { try { return lstatSync(file).isSymbolicLink(); } catch { return false; } })()) {
if (lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile() || !readFileSync(file).equals(Buffer.from(data)))
throw new Error(`An existing file is preserved: ${file}`);
return;
}
writeFileSync(file, data, { flag: "wx", mode: 0o600 });
}
function connectionRef(home: string, bindingId: string): string { return join(home, "environment-secrets", managedId(bindingId), "work.json"); }
function readConnection(file: string): WorkConnection {
const stat = lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384 || (stat.mode & 0o077)) throw new Error("The managed work connection must be a private regular file");
const value = JSON.parse(readFileSync(file, "utf8")) as WorkConnection;
if (value.version !== 1 || typeof value.token !== "string" || !value.token || typeof value.mcpUrl !== "string") throw new Error("The work connection is invalid");
return value;
}
async function prepareWork(home: string, input: { bindingId: string; seat: string; rig: string; mcpUrl: string; artifactRoot: string }): Promise<string> {
const url = new URL(input.mcpUrl);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || !url.pathname.endsWith("/mcp"))
throw new Error("The work endpoint must be an HTTP(S) MCP URL without embedded credentials");
if (!/^[^\s@]+@[^\s@]+$/.test(input.seat) || !input.rig) throw new Error("A canonical seat and its rig are required");
const file = connectionRef(home, input.bindingId), grantName = `environment-${input.bindingId}`;
if (existsSync(file)) {
const prior = readConnection(file), grants = readGrants(mcpTokenFilePath(home)) ?? [];
const grant = grantFor(grants, prior.token);
if (!grant || grant.name !== grantName || prior.seat !== input.seat || prior.rig !== input.rig || prior.mcpUrl !== url.toString()
|| prior.artifactRoot !== resolve(input.artifactRoot) || grant.seat !== input.seat || grant.owner || grant.workspace
|| grant.rigs.length !== 1 || grant.rigs[0] !== input.rig || grant.roots.length !== 0) throw new Error("The existing managed work connection changed or was revoked; preserve it and reconcile before setup");
return file;
}
const token = await addGrant(mcpTokenFilePath(home), { name: grantName, seat: input.seat, rigs: [input.rig], roots: [], workspace: false, owner: false });
const data: WorkConnection = { version: 1, mcpUrl: url.toString(), seat: input.seat, rig: input.rig, grantName, artifactRoot: resolve(input.artifactRoot), token };
preserveWrite(file, JSON.stringify(data) + "\n");
return file;
}
export function environmentCommand(override?: EnvironmentCommandDeps): Command {
const cmd = new Command("environment").description("Explicit OpenShell execution placement and verified artifact return; not rig env (services)");
const deps = (): EnvironmentCommandDeps => override ?? { lifecycleDeps: realDeps(), clientFactory: url => new DaemonClient(url) };
const client = async () => { const d = deps(), status = await getDaemonStatus(d.lifecycleDeps); if (!daemonStatusGuard(status)) throw new Error("A reachable daemon is required"); return d.clientFactory(getDaemonUrl(status)); };
const home = () => deps().home ?? getOpenRigHome();
const report = (value: unknown) => console.log(JSON.stringify(value, null, 2));
const action = (fn: (...args: any[]) => Promise<void>) => async (...args: any[]) => { try { await fn(...args); } catch (error) { console.error((error as Error).message); process.exitCode = 1; } };
const checked = <T extends Record<string, any>>(res: { status: number; data: T }): T => { if (res.status >= 400) throw new Error(res.data?.error ?? `Environment request failed (${res.status})`); return res.data; };
cmd.command("setup").description("Inspect the pinned vendor CLI and install the maintained example without overwriting another preset")
.requiredOption("--directory <path>", "New or matching example directory")
.option("--binding <id>", "Prepare this binding's normal scoped MCP work connection")
.option("--seat <address>").option("--rig <name>").option("--mcp-url <url>", "Existing authenticated MCP HTTP listener, as reached by the guest")
.option("--guest-base-url <origin>", "Guest-only activity listener origin (not the main daemon API); required with work authority")
.option("--artifact-root <path>", "Host returned-artifact root, bound to this environment")
.option("--json", "JSON receipt; never credential values")
.action(action(async (opts: { directory: string; binding?: string; seat?: string; rig?: string; mcpUrl?: string; guestBaseUrl?: string; artifactRoot?: string }) => {
const binary = await promisify(execFile)("openshell", ["--version"], { timeout: 10_000, maxBuffer: 4096 });
if (!/\b0\.1\.6\b/.test(binary.stdout)) throw new Error("This reference requires OpenShell 0.1.6; no compute was prepared");
const root = resolve(opts.directory), assets = deps().assets ?? join(getDaemonPath(), "assets", "openshell");
const names = ["guest.py", "Dockerfile", "policy.yaml", "rig.yaml", "agents/worker/agent.yaml", "agents/worker/role.md", "agents/coordinator/agent.yaml", "agents/coordinator/role.md", "README.md", "driver-v1.schema.json", "claude-code-provider.yaml"];
for (const name of names) {
let data = readFileSync(join(assets, name));
if (name === "policy.yaml" && opts.mcpUrl && opts.guestBaseUrl) {
const mcp = new URL(opts.mcpUrl), activity = new URL(opts.guestBaseUrl);
if (![mcp, activity].every(url => ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash)
|| activity.pathname !== "/") throw new Error("Explicit guest endpoints without embedded credentials are required");
const mainPort = Number(process.env.OPENRIG_PORT ?? "7433");
const ports = [mcp, activity].map(url => Number(url.port || (url.protocol === "https:" ? 443 : 80)));
if (ports.includes(mainPort) || ports[0] === ports[1]) throw new Error("Select separate MCP and guest-only activity ports, never the daemon main port");
const policy = parseYaml(data.toString("utf8"));
for (const [name, url] of [["openrig_mcp", mcp], ["openrig_activity", activity]] as const) {
policy.network_policies[name].endpoints[0].host = url.hostname;
policy.network_policies[name].endpoints[0].port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
}
data = Buffer.from(stringifyYaml(policy));
}
preserveWrite(join(root, name), data);
}
preserveWrite(join(home(), "community-harnesses", "openshell-claude.yaml"), readFileSync(join(assets, "preset.yaml")));
let ref: string | undefined;
if (opts.binding || opts.seat || opts.rig || opts.mcpUrl) {
if (!opts.binding || !opts.seat || !opts.rig || !opts.mcpUrl || !opts.guestBaseUrl) throw new Error("Preparing work authority needs --binding, --seat, --rig, --mcp-url and --guest-base-url together");
ref = await prepareWork(home(), { bindingId: opts.binding, seat: opts.seat, rig: opts.rig, mcpUrl: opts.mcpUrl, artifactRoot: resolve(opts.artifactRoot ?? join(root, "artifacts")) });
}
report({ version: 1, driver: "openshell", vendorVersion: "0.1.6", directory: root,
...(ref ? { workConnectionRef: ref } : {}), effect: "reference and preset installed; no environment or native process created" });
}));
for (const verb of ["create", "attach"] as const) {
const sub = cmd.command(verb).argument("<binding>").description(verb === "create" ? "Journal intent, then explicitly create one owned sandbox; retries reconcile the same operation" : "Verify and bind an exact existing resource as an attached consumer")
.requiredOption("--seat <address>").option("--node <id>", "Existing node ID; otherwise claimed by the exact named seat on its first launch")
.requiredOption("--gateway <ref>").requiredOption("--workspace <ref>").requiredOption("--name <name>")
.requiredOption("--image <digest>").requiredOption("--provider <ref>").requiredOption("--policy <path>")
.requiredOption("--guest-base-url <origin>", "Guest-reachable daemon origin for the single P6 activity grant")
.option("--operation <id>", "Stable effect identity (default verb plus binding ID)").option("--json");
if (verb === "attach") sub.requiredOption("--resource-id <id>", "Immutable live resource ID").option("--fingerprint <value>", "Expected host-key fingerprint");
sub.action(action(async (binding: string, opts: Record<string, string>) => {
managedId(binding); const ref = connectionRef(home(), binding), work = readConnection(ref);
if (work.seat !== opts.seat) throw new Error("The managed work connection belongs to another seat; no provider operation was requested");
const body = { version: 1, bindingId: binding, operationId: opts.operation ?? `${verb}-${binding}`, nodeId: opts.node, seat: opts.seat,
baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: opts.guestBaseUrl, coordinationRef: ref, artifactRoot: work.artifactRoot,
placement: { gateway: opts.gateway, workspace: opts.workspace, name: opts.name, image: opts.image, provider: opts.provider, policy: realpathSync(opts.policy!) },
...(verb === "attach" ? { resourceId: opts.resourceId, fingerprint: opts.fingerprint } : {}) };
const res = await (await client()).post<Record<string, any>>(`/api/environments/${verb}`, body, { timeoutMs: 120_000 });
report({ ...checked(res), operationId: body.operationId, effect: res.status === 202 ? "unconfirmed; reconcile this same operation" : "binding-confirmed; native launch not requested" });
}));
}
cmd.command("inspect").description("Read stored binding receipts or inspect the exact live resource").argument("<binding>").option("--live", "Observe the exact provider resource; unavailable is not absence").option("--json")
.action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get<Record<string, any>>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`)))));
cmd.command("connect").argument("<binding>").requiredOption("--generation <id>").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped")
.action(action(async (id: string, opts: { generation: string }) => {
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`));
checked(await c.post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/validate`, { generation: opts.generation }));
const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name];
await new Promise<void>((resolveExit, reject) => {
const child = spawn("openshell", args, { stdio: "inherit" });
child.once("error", reject); child.once("exit", code => code === 0 ? resolveExit() : reject(new Error(`The environment viewer exited ${code}; native and compute state require inspection`)));
});
}));
cmd.command("export").argument("<binding>").requiredOption("--request <path>", "Versioned artifact request JSON with operation/task/generation/path/hash/bytes/schema/context")
.option("--json").description("Transfer actual bytes through the existing authenticated MCP work route, never an activity token")
.action(action(async (binding: string, opts: { request: string }) => {
const work = readConnection(connectionRef(home(), binding)), request = JSON.parse(readFileSync(opts.request, "utf8"));
const transport = new StreamableHTTPClientTransport(new URL(work.mcpUrl), { requestInit: { headers: { Authorization: `Bearer ${work.token}` } } });
const mcp = new Client({ name: "openrig-environment-export", version: "1" });
try {
await mcp.connect(transport);
const result = await mcp.callTool({ name: "rig_environment_export", arguments: { ...request, bindingId: binding } });
if (result.isError) throw new Error((result.content as Array<{ text?: string }>).map(c => c.text ?? "").join("\n"));
report(result.structuredContent ?? result.content);
} finally { await mcp.close(); }
}));
cmd.addHelpText("after", "\nStart with rig environment setup --help. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. One native run per binding in v1; shutdown and exact restore are not implemented here.\n");
return cmd;
}
+3
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { environmentCommand, type EnvironmentCommandDeps } from "./commands/environment.js";
import { Command } from "commander";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -143,6 +144,7 @@ export interface ProgramDeps {
skillDeps?: StatusDeps;
agentImageDeps?: StatusDeps;
communityHarnessDeps?: StatusDeps;
environmentDeps?: EnvironmentCommandDeps;
forkDeps?: StatusDeps;
workspaceDeps?: WorkspaceDeps;
whoamiDeps?: StatusDeps;
@@ -227,6 +229,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(skillCommand(depsOverride?.skillDeps));
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
program.addCommand(communityHarnessCommand(depsOverride?.communityHarnessDeps));
program.addCommand(environmentCommand(depsOverride?.environmentDeps));
program.addCommand(forkCommand(depsOverride?.forkDeps));
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
program.addCommand(rigModeCommand(depsOverride?.rigModeDeps));
+1
View File
@@ -65,6 +65,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
["skill", async () => (await import("./commands/skill.js")).skillCommand()],
["agent-image", async () => (await import("./commands/agent-image.js")).agentImageCommand()],
["community-harness", async () => (await import("./commands/community-harness.js")).communityHarnessCommand()],
["environment", async () => (await import("./commands/environment.js")).environmentCommand()],
["fork", async () => (await import("./commands/fork.js")).forkCommand()],
["workspace", async () => (await import("./commands/workspace.js")).workspaceCommand()],
["mode", async () => (await import("./commands/rig-mode.js")).rigModeCommand()],
+23
View File
@@ -523,6 +523,7 @@ export const MCP_OPERATIONS: ReadonlyArray<{ tool: string; routes: readonly stri
{ tool: "rig_queue_list", routes: ["GET /api/queue/list"], effect: "read", access: "work" },
{ tool: "rig_queue_claim", routes: ["POST /api/queue/:qitemId/claim"], effect: "destructive", access: "work" },
{ tool: "rig_queue_update", routes: ["POST /api/queue/:qitemId/update"], effect: "destructive", access: "work" },
{ tool: "rig_environment_export", routes: ["GET /api/queue/:qitemId", "POST /api/environments/:bindingId/export"], effect: "additive", access: "work" },
{ tool: "rig_telemetry_events", routes: ["GET /api/telemetry/v1/events"], effect: "read", access: "owner" },
{ tool: "rig_whoami", routes: ["GET /api/whoami"], effect: "read", access: "work" },
{ tool: "rig_team", routes: ["GET /api/rigs/:rigId", "GET /api/rigs/:rigId/nodes", "GET /healthz"], effect: "read", access: "work" },
@@ -1384,6 +1385,28 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
},
);
// Artifact publication uses this actual authenticated work caller, never a body actor or activity token.
if (offered("rig_environment_export")) server.registerTool(
"rig_environment_export",
{
description: "Transfer a bound worker's actual JSON artifact to the host, verify its bytes/schema/context and append a durable receipt to its existing work. Does not close work, stop compute or restore native history. Requires an authenticated HTTP work connection.",
inputSchema: z.object({
bindingId: z.string(), operationId: z.string(), taskId: z.string(), generation: z.string(), path: z.string(),
sha256: z.string(), bytes: z.number().int().positive().max(1048576),
schema: z.object({ format: z.literal("json-object/v1"), fields: z.record(z.string(), z.enum(["string", "number", "boolean"])), required: z.array(z.string()) }),
context: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
}),
annotations: ADDITIVE,
},
async ({ bindingId, ...input }) => {
if (!access) return { content: [{ type: "text" as const, text: "This export requires a validated HTTP work grant; a declared stdio/local identity is not authentication." }], isError: true };
try {
const denied = await itemOutOfScope(input.taskId, "owner"); if (denied) return denied;
return mapResult(await client.post(`/api/environments/${encodeURIComponent(bindingId)}/export`, input), undefined, { structured: true });
} catch (error) { return caught(error); }
},
);
// 24. rig_telemetry_events — one bounded page of event metadata, the page `rig telemetry events` reads
if (offered("rig_telemetry_events")) server.registerTool(
"rig_telemetry_events",
@@ -0,0 +1,82 @@
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { afterEach, expect, it, vi } from "vitest";
import { createHash } from "node:crypto";
import { mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serve } from "@hono/node-server";
import type { AddressInfo } from "node:net";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { EnvironmentPlatform } from "../../daemon/src/domain/environment-platform.js";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
import { addGrant, revokeGrant, startMcpHttpServer } from "../src/mcp-http.js";
const cleanup: Array<() => unknown | Promise<unknown>> = [];
afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close(); });
const hash = (data: string) => createHash("sha256").update(data).digest("hex");
it("returns real artifact bytes under the authenticated work grant, refusing wrong seat and revoked credentials", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-http-")));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const db = createDb(":memory:"); migrate(db, ALL_MIGRATIONS); cleanup.push(() => db.close());
const identity = join(root, "identity.sqlite"), idDb = createDb(identity);
idDb.exec("CREATE TABLE self_host_identity (singleton INTEGER PRIMARY KEY, host_id TEXT)");
idDb.prepare("INSERT INTO self_host_identity VALUES (1, 'environment-test-host')").run(); idDb.close();
vi.stubEnv("OPENRIG_DB", identity); setSelfHostId("environment-test-host");
cleanup.push(() => { setSelfHostId(null); vi.unstubAllEnvs(); });
const artifact = JSON.stringify({ task: "export-task", revision: 1, report: "useful bytes" });
let downloads = 0;
const resource = { id: "immutable-id", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
const driver = { version: 1 as const, list: async () => ({ complete: true, resources: [] }), create: async (input: { operationId: string }) => { resource.operationId = input.operationId; return resource; },
inspect: async () => resource, upload: async () => {}, read: async () => Buffer.alloc(0),
download: async (_binding: unknown, _source: string, destination: string) => { downloads++; writeFileSync(destination, artifact); } };
const queue = new QueueRepository(db, new EventBus(db)); queue.attachOutbox(new OutboxHandler(db));
const fixture = createTestApp(db, { appDeps: { queueRepo: queue, environmentDriver: driver, environmentArtifactRoot: join(root, "artifacts") } as never });
const rig = fixture.rigRepo.createRig("team"), node = fixture.rigRepo.addNode(rig.id, "team.worker", { runtime: "openshell-claude", cwd: root });
fixture.sessionRegistry.registerSession(node.id, "worker@team");
const policy = join(root, "policy.yaml"); writeFileSync(policy, "version: 1\n");
const platform = new EnvironmentPlatform(db, driver);
await platform.create({ version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id, seat: "worker@team", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7782",
placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } });
await platform.project("binding-one", { operationId: "projection-one", generation: "generation-one", files: [] });
// This is the existing queue writer, not a separate guest task store.
const created = await fixture.app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "x-openrig-session": "coordinator@team" }, body: JSON.stringify({ qitemId: "export-task", destinationSession: "worker@team", body: "Return the selected report" }) });
expect(created.status, JSON.stringify(await created.clone().json())).toBeLessThan(300);
const daemon = await new Promise<ReturnType<typeof serve>>(resolve => {
const server = serve({ fetch: fixture.app.fetch, hostname: "127.0.0.1", port: 0 }, () => resolve(server));
});
cleanup.push(() => new Promise<void>(resolve => daemon.close(() => resolve())));
const daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
const tokenFile = join(root, "tokens.json");
const token = await addGrant(tokenFile, { name: "worker-grant", seat: "worker@team", rigs: ["team"], roots: [join(root, "artifacts")], workspace: false, owner: false });
const wrongToken = await addGrant(tokenFile, { name: "other-grant", seat: "other@team", rigs: ["team"], roots: [], workspace: false, owner: false });
const mcp = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile }); cleanup.push(() => mcp.close());
const connect = async (credential: string) => {
const client = new Client({ name: "environment-proof", version: "1" });
await client.connect(new StreamableHTTPClientTransport(new URL(mcp.url), { requestInit: { headers: { Authorization: `Bearer ${credential}`, "X-OpenRig-Principal": JSON.stringify({ source: "file", id: "forged" }) } } }));
cleanup.push(() => client.close()); return client;
};
const bad = new Client({ name: "environment-invalid", version: "1" });
await expect(bad.connect(new StreamableHTTPClientTransport(new URL(mcp.url), { requestInit: { headers: { Authorization: "Bearer invalid" } } }))).rejects.toThrow();
await bad.close();
const owner = await connect(token), other = await connect(wrongToken);
const request = { bindingId: "binding-one", operationId: "export-one", taskId: "export-task", generation: "generation-one", path: "report.json", sha256: hash(artifact), bytes: Buffer.byteLength(artifact),
schema: { format: "json-object/v1", fields: { task: "string", revision: "number", report: "string" }, required: ["task", "revision", "report"] }, context: { task: "export-task", revision: 1 } };
const denied = await other.callTool({ name: "rig_environment_export", arguments: request }); expect(denied.isError).toBe(true); expect(downloads).toBe(0);
const result = await owner.callTool({ name: "rig_environment_export", arguments: request }); expect(result.isError).not.toBe(true);
const receipt = result.structuredContent as Record<string, any>;
expect(receipt).toMatchObject({ actor: "worker@team", identityProvenance: "principal:v1", identityPrincipal: JSON.stringify({ source: "file", id: "worker-grant" }), sha256: hash(artifact) });
expect(readFileSync(receipt.path, "utf8")).toBe(artifact); expect(downloads).toBe(1);
const note = db.prepare("SELECT identity_provenance, identity_principal FROM queue_transitions WHERE qitem_id = 'export-task' ORDER BY transition_id DESC LIMIT 1").get();
expect(note).toMatchObject({ identity_provenance: "principal:v1", identity_principal: JSON.stringify({ source: "file", id: "worker-grant" }) });
expect(await revokeGrant(tokenFile, "worker-grant")).toBe(true);
await expect(owner.callTool({ name: "rig_environment_export", arguments: request })).rejects.toThrow();
expect(downloads).toBe(1);
});
+6 -5
View File
@@ -44,10 +44,10 @@ describe("MCP Server", () => {
if (cleanup) await cleanup();
});
// T1: MCP server lists all 36 tools: the original 18, the five queue tools, the telemetry events read, restore status, whoami,
// T1: MCP server lists all 37 tools: the original 18, the five queue tools, the telemetry events read, restore status, whoami,
// the team, slice, mission and two context reads, the artifact read, the decision room and delegated decision, and the
// worker's handoff and unclaim
it("lists all 36 tools", async () => {
it("lists all 37 tools", async () => {
await setup();
const result = await mcpClient.listTools();
const names = result.tools.map((t) => t.name).sort();
@@ -64,6 +64,7 @@ describe("MCP Server", () => {
"rig_context_pack",
"rig_discover",
"rig_down",
"rig_environment_export",
"rig_human_decision_room",
"rig_mission",
"rig_ps",
@@ -240,7 +241,7 @@ describe("MCP Server", () => {
const reads = ["rig_agent_validate", "rig_bundle_inspect", "rig_capture", "rig_chatroom_watch", "rig_ps", "rig_queue_list", "rig_queue_show",
"rig_restore_status", "rig_rig_nodes", "rig_rig_validate", "rig_snapshot_list", "rig_status", "rig_telemetry_events", "rig_whoami",
"rig_team", "rig_slices", "rig_mission", "rig_context_pack", "rig_context_get", "rig_artifact_read", "rig_human_decision_room"];
const additive = ["rig_add", "rig_chatroom_send", "rig_queue_create", "rig_snapshot_create"];
const additive = ["rig_environment_export", "rig_add", "rig_chatroom_send", "rig_queue_create", "rig_snapshot_create"];
// Discover refreshes and retires discovered facts; claim ends a blocked row's wait. Both change existing state.
// A delegated decision closes the person's ask; a handoff closes the item passed on; an unclaim ends a claim.
const destructive = ["rig_bind", "rig_discover", "rig_down", "rig_queue_claim", "rig_queue_decide", "rig_queue_handoff", "rig_queue_unclaim",
@@ -689,7 +690,7 @@ describe("MCP Server", () => {
// Verify server is responsive
const result = await mcpClient.listTools();
expect(result.tools.length).toBe(36);
expect(result.tools.length).toBe(37);
// Clean disconnect
await cleanup();
@@ -703,7 +704,7 @@ describe("MCP Server", () => {
await client2.connect(ct2);
const result2 = await client2.listTools();
expect(result2.tools.length).toBe(36);
expect(result2.tools.length).toBe(37);
await client2.close();
await server2.close();
@@ -0,0 +1,15 @@
# Supply a digest-pinned base with Python3 and Node22+, and a trusted Claude Code 2.1.310 binary.
# No credentials, histories or work payloads belong in this build context.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
USER 0
COPY --chmod=0755 claude /usr/local/bin/claude
COPY --chmod=0644 guest.py /opt/openrig/guest.py
RUN python3 --version && node --version && /usr/local/bin/claude --version \
&& mkdir -p /sandbox/openrig/control /sandbox/openrig/work/artifacts /sandbox/openrig/native \
&& chown -R 1000:1000 /sandbox/openrig
USER 1000:1000
WORKDIR /sandbox/openrig/work
ENV CLAUDE_CONFIG_DIR=/sandbox/openrig/native
ENV DISABLE_AUTOUPDATER=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
ENTRYPOINT ["python3", "/opt/openrig/guest.py"]
@@ -0,0 +1,61 @@
# OpenShell worker reference, driver version 1
This maintained reference binds an ordinary OpenRig seat to one OpenShell sandbox. It supports explicit create/attach, required context projection and verified JSON artifact return. It requires OpenShell 0.1.6, a Linux image with Python 3, Node 22 or later and Claude Code 2.1.310 at `/usr/local/bin/claude`, an API-backed OpenShell provider and an existing authenticated OpenRig MCP HTTP listener. Subscription/OAuth credential migration is outside this reference.
The image recipe is an input recipe, not a published prebuilt image or a reproducible-image claim. Supply your own trusted, digest-pinned base and the native executable. No credentials, histories or task data belong in its context. Build it explicitly with your normal image tooling, then supply its resulting digest to `create`. Provider registration and image construction can incur costs; setup does neither. The helper releases native startup only after Core verifies the selected destination files and installs the two scoped connections. Its initial preparation latch expires after ten minutes; expiry is a refused start, never a cold worker. Create immediately before `rig up`.
## Install the reference and work connection
Start an MCP HTTP listener using `rig mcp serve --http --help` and the normal member-grant file on this daemon host. Select a guest-reachable address and explicit port. Setup adds one non-owner, one-seat, one-rig work grant in that existing file and stores its bearer credential in a private managed file. It prints only the file reference. Existing files/presets with different bytes are preserved.
```sh
rig environment setup --directory ./openshell-example --binding worker-one \
--seat team-worker@openshell-example --rig openshell-example \
--mcp-url http://host.openshell.internal:7781/mcp \
--guest-base-url http://host.openshell.internal:7782 --json
```
Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The listener serves only `POST /api/activity/hooks`, using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential.
Import `claude-code-provider.yaml` using OpenShell's provider-profile commands, then create a provider with its `ANTHROPIC_API_KEY` environment lookup. See `openshell provider --help`; keep the master credential in provider custody, never an argument or source file. `create` passes that provider reference with `--no-auto-providers`. The guest helper saves only the provider-injected reference in a private file and uses Claude's `apiKeyHelper`. It does not copy an owner authentication home.
Build `Dockerfile` with the selected digest-pinned `BASE_IMAGE` and your trusted `claude` executable beside it. Use the resulting actual digest, gateway, workspace and provider reference:
```sh
rig environment create worker-one --seat team-worker@openshell-example \
--gateway YOUR_GATEWAY --workspace YOUR_WORKSPACE --name worker-one \
--image YOUR_IMAGE@sha256:YOUR_64_HEX_DIGEST --provider YOUR_PROVIDER \
--policy ./openshell-example/policy.yaml \
--guest-base-url http://host.openshell.internal:7782 --json
rig up ./openshell-example/rig.yaml
```
Setup installs the `openshell-claude` community preset. The host coordinator uses ordinary Claude Code; the worker selects that preset. The exact seat reservation is claimed by its first stable node. OpenRig maps resolved guidance, skills and plugins into the guest work root, stages all applicable startup files before native launch, verifies bytes again at the latch and records the native UUID observed in that resource. A process receipt establishes identity, not successful authentication, actual ingestion or a model response. Activity hooks are recorded under their grant; this community runtime currently uses pane sampling to decide activity and does not claim the runner inventory. Work and activity grants are separate. The guest never receives the install-wide hook token.
For an existing compatible sandbox, use `attach` with the same placement flags plus `--resource-id` and optionally `--fingerprint`. Its immutable ID, name, workspace and selected fingerprint must match live inspection. Attached ownership remains attached; this cut never deletes its native state, storage or compute. An arbitrary preexisting image without the fixed helper is incompatible, even if its name matches.
An attach refusal permits a corrected attach, and an intent that never requested a provider effect can be replaced with a new operation. Once create was requested it must be reconciled; it is not silently abandoned.
A `202` result means the create is unconfirmed. Repeat the same command and operation identity to reconcile; do not choose a new operation to force another create. A matching operation label and immutable resource identify a lost response. Incomplete listings, duplicate matches and provider unavailability remain distinct from absence. `inspect --live` refreshes the exact resource; plain inspect labels its stored receipt.
## Return actual work
The coordinator assigns ordinary queue work with a concrete JSON-object schema and task/revision context. The worker writes beneath `/sandbox/openrig/work/artifacts`, computes SHA256 and byte count, and calls `rig_environment_export` through its authenticated MCP connection. Alternatively, a host caller can use `rig environment export worker-one --request request.json --json` to use that same managed work grant. Example request:
```json
{
"operationId": "report-task-one", "taskId": "YOUR_TASK_ID",
"generation": "YOUR_OCCUPANT_GENERATION", "path": "report.json",
"sha256": "YOUR_64_HEX_SHA256", "bytes": 123,
"schema": {"format": "json-object/v1", "fields": {"task": "string", "revision": "number", "report": "string"}, "required": ["task", "revision", "report"]},
"context": {"task": "YOUR_TASK_ID", "revision": 1}
}
```
The fixed helper opens a single file through non-symlink directory descriptors and reads at most 1 MiB plus one rejection byte. Core bounds the captured response and decoded bytes before any host file write; vendor tar download/extraction is not used. The host checks path/type/size/hash/schema/context, publishes to a fresh no-overwrite path, and atomically appends a receipt to the existing task. Publication before a lost receipt is reconciled from verified host bytes even if the guest is gone. The task remains open until its owner reports completion. The receipt records the authenticated grant-derived caller; a declared body actor and the activity token cannot impersonate a work principal. Preserve the same operation on retry. Changed host bytes, generation, task owner or request refuse publication/receipt rather than overwriting or fabricating completion.
## Boundaries
Driver protocol 1 is documented by `driver-v1.schema.json` and the daemon's `EnvironmentDriver` interface. Core owns placement provenance, operation intent, resolved-file selection and receipts; the driver owns fixed OpenShell CLI transport and guest path mapping. There is no generic plugin loader or alternative task store. Projection is bounded to 4096 files, 1 MiB per file and 32 MiB total. Unsupported runtime resources/subagents refuse the selected launch explicitly; ordinary host runtimes keep their existing behavior.
Native stop, compute stop/delete, checkpoint and exact native restore are not implemented by this driver version. Inspect reports these lifecycle operations as unsupported; closing a viewer proves only detachment. Do not use it as a shutdown receipt. A previous requested or confirmed native execution blocks a fresh launch until lifecycle/restore can reconcile it. No native, provider, lifecycle or restore conformance is claimed by source tests; qualify the maintained reference separately on its exact build.
@@ -0,0 +1,20 @@
name: openshell-coordinator
version: "1.0"
profiles:
default:
uses:
guidance: []
plugins: [openrig-core]
resources:
plugins:
- id: openrig-core
source:
kind: local
path: openrig-home:plugins/openrig-core
startup:
files:
- path: role.md
orientation: role
delivery_hint: send_text
required: true
actions: []
@@ -0,0 +1 @@
Coordinate useful work with the OpenShell worker through the existing OpenRig queue. Give each job a concrete JSON artifact contract including its task id, revision, required fields and relative artifact path. Read the verified host artifact before accepting completion. An intent, native process or posted summary alone does not establish returned bytes. Do not stop or delete an attached environment.
@@ -0,0 +1,20 @@
name: openshell-worker
version: "1.0"
profiles:
default:
uses:
guidance: []
plugins: [openrig-core]
resources:
plugins:
- id: openrig-core
source:
kind: local
path: openrig-home:plugins/openrig-core
startup:
files:
- path: role.md
orientation: role
delivery_hint: send_text
required: true
actions: []
@@ -0,0 +1,3 @@
You are the team's contained worker. Use the authenticated OpenRig MCP tools for identity, team and queue work. Read CLAUDE.md and the current files it lists before acting. Host CLI paths are not guest paths.
Claim assigned work. Produce a JSON object under /sandbox/openrig/work/artifacts. Read your binding id from OPENRIG_ENVIRONMENT_BINDING and generation from OPENRIG_OCCUPANT_GENERATION. Compute its SHA256 and byte count, then call rig_environment_export with the binding id, task id, occupant generation, stable operation id, relative path, expected hash/bytes, json-object/v1 schema and expected task/revision context. Read the returned verified artifact receipt. Only then report completion through the ordinary queue; an export does not close your work. If export is unconfirmed, retain the same operation and reconcile it rather than inventing a receipt.
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Example provider profile. OpenShell does not load it; import it explicitly:
# openshell provider profile lint -f providers/claude-code.yaml
# openshell provider profile import -f providers/claude-code.yaml --global
#
# Copy and edit this file rather than importing it unchanged. `binaries` is the
# least-privilege control that decides which processes may reach the endpoints
# below, so it has to name the paths in *your* image.
#
# Client binaries: claude.
# Reference layout: the Claude Code CLI at /usr/bin/claude or
# /usr/local/bin/claude. An npm global install resolves
# elsewhere (for example /usr/lib/node_modules/@anthropic-ai/**);
# add that path if your image installs it that way.
# Credential scope: ANTHROPIC_API_KEY or CLAUDE_API_KEY, sent as the x-api-key
# header to the endpoints below and nowhere else.
# Endpoint access: api.anthropic.com for inference; statsig.anthropic.com and
# sentry.io for the CLI's telemetry and error reporting. Drop
# the last two if your policy forbids that traffic.
# Smoke test: openshell sandbox create --provider <name> -- \
# claude -p 'reply with OK'
id: claude-code
display_name: Claude Code
description: Claude Code CLI
category: agent
inference_capable: true
credentials:
- name: api_key
description: Anthropic API key used by Claude Code
env_vars: [ANTHROPIC_API_KEY, CLAUDE_API_KEY]
required: true
auth_style: header
header_name: x-api-key
discovery:
credentials: [api_key]
endpoints:
- host: api.anthropic.com
port: 443
protocol: rest
access: read-write
enforcement: enforce
- host: statsig.anthropic.com
port: 443
protocol: rest
access: read-write
enforcement: enforce
- host: sentry.io
port: 443
protocol: rest
access: read-write
enforcement: enforce
binaries: [/usr/bin/claude, /usr/local/bin/claude]
@@ -0,0 +1,81 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://www.openrig.dev/contracts/environment-driver-v1",
"title": "OpenRig environment driver version 1 binding",
"type": "object",
"required": [
"version",
"bindingId",
"seat",
"baseRuntime",
"guestRoot",
"guestBaseUrl",
"placement"
],
"properties": {
"version": {
"const": 1
},
"bindingId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$"
},
"nodeId": {
"type": "string"
},
"seat": {
"type": "string"
},
"baseRuntime": {
"const": "claude-code"
},
"guestRoot": {
"const": "/sandbox/openrig"
},
"guestBaseUrl": {
"type": "string"
},
"coordinationRef": {
"type": "string"
},
"artifactRoot": {
"type": "string"
},
"placement": {
"type": "object",
"required": [
"gateway",
"workspace",
"name",
"image",
"provider",
"policy"
],
"properties": {
"gateway": {
"type": "string"
},
"workspace": {
"type": "string"
},
"name": {
"type": "string"
},
"image": {
"type": "string"
},
"provider": {
"type": "string"
},
"policy": {
"type": "string"
},
"policySha256": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""OpenRig environment-driver v1 guest operations and bounded Claude startup latch.
Baked into the selected image. No provider credentials, model policy, histories or
work payloads are built into this helper. Native restore belongs to a separate effect.
"""
import argparse
import base64
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import stat
import time
import uuid
LIMIT = 1024 * 1024
DEFAULT_ROOT = "/sandbox/openrig"
class Refused(Exception):
pass
def digest(data):
return hashlib.sha256(data).hexdigest()
def safe(root, raw):
root = Path(root)
path = Path(raw)
if not root.is_absolute() or root == Path("/") or any(part.is_symlink() for part in (root, *root.parents)):
raise Refused("environment_root_invalid")
if not path.is_absolute():
path = root / path
try:
parts = path.relative_to(root).parts
except ValueError:
raise Refused("environment_path_outside_root")
if not parts or any(part in ("", ".", "..") for part in parts):
raise Refused("environment_path_invalid")
current = root
for part in parts:
current = current / part
if current.is_symlink():
raise Refused("environment_symlink")
return path
def regular(root, raw, limit=LIMIT):
path = safe(root, raw)
parts = path.relative_to(root).parts
directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
try:
for part in (*Path(root).parts[1:], *parts[:-1]):
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
os.close(directory)
directory = child
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
try:
before = os.fstat(fd)
if not stat.S_ISREG(before.st_mode) or before.st_size > limit:
raise Refused("environment_file_invalid")
with os.fdopen(fd, "rb", closefd=False) as stream:
data = stream.read(limit + 1)
after = os.fstat(fd)
if len(data) > limit or len(data) != before.st_size or after.st_size != before.st_size or after.st_mtime_ns != before.st_mtime_ns:
raise Refused("environment_file_invalid")
return data
finally:
os.close(fd)
except FileNotFoundError:
raise Refused("environment_file_missing")
except OSError:
raise Refused("environment_file_invalid")
finally:
os.close(directory)
def atomic(root, raw, data):
path = safe(root, raw)
path.parent.mkdir(parents=True, exist_ok=True)
safe(root, path)
temp = path.parent / (".openrig-" + uuid.uuid4().hex)
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
finally:
if temp.exists():
temp.unlink()
def descriptor(root, pending=False):
name = "control/launch.pending.json" if pending else "control/launch.json"
value = json.loads(regular(root, name))
if value.get("version") != 1 or not value.get("generation") or not value.get("bindingId"):
raise Refused("environment_descriptor_invalid")
for file in value["files"]:
data = regular(root, file["path"])
if len(data) != file["bytes"] or digest(data) != file["sha256"]:
raise Refused("environment_projection_changed")
# P1-P3 never silently replace a requested native conversation with a fresh one.
if value.get("resumeToken"):
raise Refused("environment_native_restore_unsupported")
if not re.fullmatch(r"[0-9a-f-]{36}", value["nativeId"]):
raise Refused("environment_native_identity_invalid")
return value
def launch(root):
deadline = time.monotonic() + 600
while not safe(root, "control/launch.json").exists():
if time.monotonic() >= deadline:
raise Refused("environment_preparation_expired")
time.sleep(.2)
value = descriptor(root)
fd = os.open(safe(root, "control/native.lock"), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
os.set_inheritable(fd, True)
env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(Path(root) / "native"),
"CLAUDE_CONFIG_DIR": str(Path(root) / "native")}
secret = json.loads(regular(root, "control/activity.json"))
allowed = {"OPENRIG_URL", "OPENRIG_ACTIVITY_HOOK_TOKEN", "OPENRIG_NODE_ID", "OPENRIG_SESSION_NAME", "OPENRIG_OCCUPANT_GENERATION"}
if set(secret) != allowed or secret["OPENRIG_OCCUPANT_GENERATION"] != value["generation"]:
raise Refused("environment_activity_projection_invalid")
env.update(secret)
env.update({"DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"OPENRIG_RUNTIME": "openshell-claude", "OPENRIG_NODE": "/usr/bin/node",
"OPENRIG_ENVIRONMENT_BINDING": value["bindingId"]})
if safe(root, "control/provider-api-ref").exists():
atomic(root, "native/settings.json", json.dumps({"apiKeyHelper": "cat /sandbox/openrig/control/provider-api-ref"}).encode())
# Provider-owned injection may supply these native endpoint references. No host environment is copied.
for name in ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
if name in os.environ:
env[name] = os.environ[name]
argv = ["/usr/local/bin/claude", "--session-id", value["nativeId"], "--strict-mcp-config", "--mcp-config", str(safe(root, "control/coordination.json"))]
for plugin in value.get("plugins", []):
argv.extend(["--plugin-dir", str(safe(root, plugin))])
if value.get("model"):
argv.extend(["--model", value["model"]])
if value.get("effort"):
argv.extend(["--effort", value["effort"]])
if value.get("permissionMode"):
argv.extend(["--permission-mode", value["permissionMode"]])
work = safe(root, "work")
work.mkdir(exist_ok=True)
os.chdir(work)
atomic(root, "control/native.json", json.dumps({"nativeId": value["nativeId"], "generation": value["generation"], "pid": os.getpid()}).encode())
os.execve(argv[0], argv, env)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--root", default=DEFAULT_ROOT)
for option in ("read", "stat", "stage", "install"):
p.add_argument("--" + option)
p.add_argument("--destination")
p.add_argument("--previous")
p.add_argument("--mode", choices=("0600", "0700"), default="0600")
p.add_argument("--prepare", action="store_true")
p.add_argument("--identity", action="store_true")
args = p.parse_args()
root = Path(args.root)
if not root.is_absolute() or any(part.is_symlink() for part in (root, *root.parents)):
raise Refused("environment_root_invalid")
root.mkdir(parents=True, exist_ok=True)
if args.stage:
path = safe(root, args.stage)
if path.exists():
raise Refused("environment_stage_exists")
path.parent.mkdir(parents=True, exist_ok=True)
return {"ok": True}
if args.read or args.stat:
data = regular(root, args.read or args.stat)
return {"ok": True, "sha256": digest(data), "bytes": len(data),
**({"base64": base64.b64encode(data).decode()} if args.read else {})}
if args.install:
data = regular(root, args.install)
destination = safe(root, args.destination)
if destination.exists():
if args.previous == "missing" or digest(regular(root, destination)) != args.previous:
raise Refused("environment_projection_conflict")
elif args.previous != "missing":
raise Refused("environment_projection_changed")
atomic(root, destination, data)
os.chmod(destination, int(args.mode, 8))
safe(root, args.install).unlink()
return {"ok": True, "sha256": digest(data), "bytes": len(data)}
if args.prepare:
value = descriptor(root, pending=True)
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
published = safe(root, "control/launch.json")
if published.exists():
if regular(root, published) != encoded:
raise Refused("environment_native_already_selected")
return {"ok": True, "generation": value["generation"], "nativeId": value["nativeId"]}
# This is the guest provider's injected reference, never an owner-host environment value.
reference = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
if not reference:
raise Refused("environment_provider_reference_missing")
atomic(root, "control/provider-api-ref", reference.encode())
Path(root, "native").mkdir(exist_ok=True)
atomic(root, "control/launch.json", encoded)
return {"ok": True, "generation": value["generation"], "nativeId": value["nativeId"]}
if args.identity:
value = json.loads(regular(root, "control/native.json"))
fields = (Path("/proc") / str(value["pid"]) / "cmdline").read_bytes().split(b"\0")
if value["nativeId"].encode() not in fields or not any(b"claude" in field for field in fields):
raise Refused("environment_native_unconfirmed")
return {"ok": True, **value}
launch(root)
if __name__ == "__main__":
try:
result = main()
if result is not None:
print(json.dumps(result))
except (Refused, ValueError, KeyError, OSError) as error:
print(json.dumps({"ok": False, "code": str(error) if isinstance(error, Refused) else "environment_guest_unconfirmed"}))
@@ -0,0 +1,33 @@
version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /lib64, /etc, /opt/openrig, /proc, /sys]
read_write: [/sandbox/openrig, /tmp]
network_policies:
openrig_mcp:
endpoints:
- host: host.openshell.internal
port: 7781
protocol: rest
access: read-write
enforcement: enforce
binaries:
- path: /usr/local/bin/claude
openrig_activity:
endpoints:
- host: host.openshell.internal
port: 7782
protocol: rest
access: read-write
enforcement: enforce
binaries:
- path: /usr/bin/node
claude_startup_connectivity:
endpoints:
- host: platform.claude.com
port: 443
protocol: rest
access: read-only
enforcement: enforce
binaries:
- path: /usr/local/bin/claude
@@ -0,0 +1,10 @@
format: 1
name: openshell-claude
command: rig environment connect
maintainer: OpenRig contributors
limits:
- Requires explicit environment create or attach before rig up; driver protocol 1, OpenShell 0.1.6 and Claude Code 2.1.310.
- Projects required context and verifies destination bytes; native ingestion and provider readiness need separate observation.
- Closing the viewer does not stop native execution or compute.
- Native stop, compute deletion, checkpoint and restore are unsupported in this first cut.
passes_model: true
+19
View File
@@ -0,0 +1,19 @@
version: "0.2"
name: openshell-example
pods:
- id: team
members:
- id: coordinator
agent_ref: local:agents/coordinator
runtime: claude-code
profile: default
cwd: .
- id: worker
agent_ref: local:agents/worker
runtime: openshell-claude
profile: default
cwd: .
edges:
- kind: delegates_to
from: team.coordinator
to: team.worker
@@ -1,3 +1,4 @@
import type { EnvironmentStartup } from "../domain/environment-startup.js";
import type {
RuntimeAdapter,
InstalledResource,
@@ -23,6 +24,7 @@ import { randomBytes } from "node:crypto";
import { shellQuote } from "./shell-quote.js";
interface CommunityHarnessAdapterDeps {
environmentStartup?: EnvironmentStartup;
tmux: Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand">;
seatLaunchEnvironment?: Pick<SeatLaunchEnvironment, "command">;
presetsDir?: () => string;
@@ -35,7 +37,8 @@ interface CommunityHarnessAdapterDeps {
/**
* A community harness, launched from its installed preset (`domain/community-harness-presets.ts`). It types the preset's
* command into the seat's pane and is ready while the pane runs something other than a shell. That's readiness, not
* identity: a process alone doesn't prove which session it is. It projects nothing and refuses fork. It resumes only a
* identity: a process alone doesn't prove which session it is. Ordinary presets project nothing and refuse fork.
* The maintained openshell-claude path delegates its optional environment operations to EnvironmentStartup. It resumes only a
* preset that declares `resume`. With `launch_result`, its launcher is told the conversation to reopen and reports what it
* did, and a conversation it says is gone is never replaced by a new one. With `names_conversation`, OpenRig names each new
* conversation and passes the saved name back on a resume. Nothing reports back, so a named seat's readiness is
@@ -44,11 +47,31 @@ interface CommunityHarnessAdapterDeps {
export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
constructor(readonly runtime: string, private readonly deps: CommunityHarnessAdapterDeps) {}
get environment() { return this.runtime === "openshell-claude" ? this.deps.environmentStartup?.platform : undefined; }
private contained(): EnvironmentStartup | undefined {
if (this.runtime !== "openshell-claude") return undefined;
if (!this.deps.environmentStartup) throw new Error("OpenShell environment operations are unavailable; no host fallback was attempted");
return this.deps.environmentStartup;
}
async preflightEnvironmentStartup(binding: NodeBinding, opts: { resumeToken?: string; forkSource?: unknown }): Promise<void> {
await this.contained()?.preflight(binding, opts);
}
async prepareEnvironmentStartup(files: ResolvedStartupFile[], binding: NodeBinding): Promise<StartupDeliveryResult> {
const contained = this.contained();
if (!contained) return { delivered: 0, failed: files.map(file => ({ path: file.path, error: "Environment startup projection is unsupported" })) };
return contained.deliverStartup(files, binding);
}
async listInstalled(_binding: NodeBinding): Promise<InstalledResource[]> {
return [];
}
async project(plan: ProjectionPlan, _binding: NodeBinding): Promise<ProjectionResult> {
const contained = this.contained();
if (contained) return contained.project(plan, _binding);
const skipped = plan.entries.map(entry => entry.effectiveId);
return {
projected: [], skipped, failed: [],
@@ -57,13 +80,17 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
}
async deliverStartup(files: ResolvedStartupFile[], _binding: NodeBinding): Promise<StartupDeliveryResult> {
const contained = this.contained();
if (contained) return { delivered: files.length, failed: [] };
return {
delivered: 0, failed: [],
...(files.length ? { warnings: [`Community harness "${this.runtime}" takes no startup files; ${files.length} not delivered: ${files.map(file => file.path).join(", ")}`] } : {}),
};
}
async launchHarness(binding: NodeBinding, opts: { name: string; resumeToken?: string; resumeType?: string; forkSource?: ForkSource; firstInputText?: string }): Promise<HarnessLaunchResult> {
async launchHarness(binding: NodeBinding, opts: { name: string; resumeToken?: string; resumeType?: string; forkSource?: ForkSource; firstInputText?: string; beforeNativeDispatch?: () => boolean }): Promise<HarnessLaunchResult> {
const contained = this.contained();
if (contained) return contained.launch(binding, opts, this.deps.tmux);
if (opts.forkSource) {
return { ok: false, error: `Fork is unsupported: community harness "${this.runtime}" is launch-only. Remove session_source for this member.` };
}
@@ -0,0 +1,81 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { randomUUID } from "node:crypto";
import { EnvironmentError, regularBytes, sha256, type EnvironmentBinding, type EnvironmentDriver, type EnvironmentResource, type Placement } from "../domain/environment-platform.js";
const runFile = promisify(execFile);
export type OpenShellCommand = (args: string[]) => Promise<string>;
/** Maintained OpenShell 0.1.6 CLI driver. No shell, dynamic plugin loader or credential copying. */
export class OpenShellDriver implements EnvironmentDriver {
readonly version = 1 as const;
constructor(private readonly command: OpenShellCommand = async args => {
try { return (await runFile("openshell", args, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 })).stdout; }
catch { throw new EnvironmentError("openshell_transport_unconfirmed", "OpenShell did not return a confirmed result; reconcile the saved operation", 503); }
}) {}
private prefix(place: Placement): string[] { return ["--gateway", place.gateway, "--workspace", place.workspace, "sandbox"]; }
private resource(value: unknown): EnvironmentResource {
const row = value as Record<string, any>;
if (!row || typeof row.id !== "string" || !row.id || typeof row.name !== "string" || typeof row.workspace !== "string"
|| typeof row.phase !== "string") throw new EnvironmentError("openshell_response_invalid", "OpenShell returned an incomplete resource identity");
return { id: row.id, name: row.name, workspace: row.workspace, phase: row.phase,
...(typeof row.host_key_fingerprint === "string" ? { fingerprint: row.host_key_fingerprint } : {}),
...(typeof row.labels?.openrigoperation === "string" ? { operationId: row.labels.openrigoperation } : {}) };
}
async list(place: Placement): Promise<{ complete: boolean; resources: EnvironmentResource[] }> {
const page = JSON.parse(await this.command([...this.prefix(place), "list", "--output", "json", "--page-size", "100"]));
if (!page || !Array.isArray(page.sandboxes) || page.sandboxes.length > 100 || typeof page.next_page_token !== "string")
throw new EnvironmentError("openshell_listing_invalid", "OpenShell returned an invalid resource listing");
return { complete: page.next_page_token === "", resources: page.sandboxes.map((row: unknown) => this.resource(row)) };
}
async create(input: { operationId: string; placement: Placement }): Promise<EnvironmentResource> {
const p = input.placement, bytes = regularBytes(p.policy);
if (sha256(bytes) !== p.policySha256) throw new EnvironmentError("environment_policy_changed", "The selected provider policy changed before create");
const stage = mkdtempSync(join(tmpdir(), "openrig-policy-"));
const policy = join(stage, "policy.yaml"); writeFileSync(policy, bytes, { mode: 0o600 });
try { return this.resource(JSON.parse(await this.command([...this.prefix(p), "create", "--name", p.name, "--from", p.image,
"--provider", p.provider, "--no-auto-providers", "--cpu", "1", "--memory", "2Gi", "--policy", policy,
"--label", `openrigoperation=${input.operationId}`, "--tty", "--detach", "--restart-policy", "never", "--output", "json",
"--", "python3", "/opt/openrig/guest.py"]))); } finally { rmSync(stage, { recursive: true }); }
}
async inspect(binding: EnvironmentBinding): Promise<EnvironmentResource> {
return this.resource(JSON.parse(await this.command([...this.prefix(binding.placement), "get", binding.placement.name, "--output", "json"])));
}
private async checked(binding: EnvironmentBinding): Promise<void> {
const actual = await this.inspect(binding), saved = binding.resource;
if (!saved || actual.id !== saved.id || actual.name !== saved.name || actual.workspace !== saved.workspace
|| saved.fingerprint && actual.fingerprint !== saved.fingerprint) throw new EnvironmentError("environment_identity_changed", "The live OpenShell resource does not match the saved binding");
if (actual.phase !== "Ready") throw new EnvironmentError("environment_not_ready", "The bound OpenShell resource is not Ready");
}
async guest(binding: EnvironmentBinding, args: string[]): Promise<Record<string, any>> {
await this.checked(binding);
const result = JSON.parse(await this.command([...this.prefix(binding.placement), "exec", binding.placement.name,
"--no-login-shell", "--timeout", "20", "--", "python3", "/opt/openrig/guest.py", "--root", binding.guestRoot, ...args]));
if (!result || result.ok !== true) throw new EnvironmentError(result?.code ?? "environment_guest_refused", "The guest operation was not confirmed");
return result;
}
async upload(binding: EnvironmentBinding, source: string, destination: string, previousHash?: string, executable = false): Promise<void> {
const transfer = randomUUID();
const stage = posix.join(binding.guestRoot, "control", `transfer-${transfer}`);
await this.guest(binding, ["--stage", stage]);
await this.checked(binding);
await this.command([...this.prefix(binding.placement), "upload", binding.placement.name, source, stage, "--no-git-ignore"]);
await this.guest(binding, ["--install", stage, "--destination", destination, "--previous", previousHash ?? "missing", "--mode", executable ? "0700" : "0600"]);
}
async read(binding: EnvironmentBinding, path: string): Promise<Buffer> {
const result = await this.guest(binding, ["--read", path]);
if (typeof result.base64 !== "string" || result.base64.length > 4 * Math.ceil(1024 * 1024 / 3)
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(result.base64) || result.base64.length % 4 !== 0)
throw new EnvironmentError("environment_file_invalid", "A bounded single-file response is required");
const bytes = Buffer.from(result.base64, "base64");
if (bytes.length > 1024 * 1024) throw new EnvironmentError("environment_file_invalid", "The returned file exceeds the byte cap");
return bytes;
}
async download(binding: EnvironmentBinding, source: string, destination: string): Promise<void> {
const bytes = await this.read(binding, source);
writeFileSync(destination, bytes, { flag: "wx", mode: 0o600 });
}
connectArgs(binding: EnvironmentBinding): string[] { return [...this.prefix(binding.placement), "connect", binding.placement.name]; }
}
+4
View File
@@ -1,3 +1,5 @@
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodeAccountHomeSchema } from "./migrations/141_node_account_home.js";
import { escalationTargetRefsSchema } from "./migrations/126_escalation_target_refs.js";
import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_readings.js";
@@ -287,4 +289,6 @@ export const ALL_MIGRATIONS: Migration[] = [
sessionAgentRunnerSchema,
nodeAccountHomeSchema,
nodeClaudeMcpSchema,
activityGrantsSchema,
environmentBindingsSchema,
];
@@ -0,0 +1,23 @@
import type { Migration } from "../migrate.js";
/**
* A seat activity grant: one seat's (node's) authority to post activity for one launch generation, for a workload that
* can't hold the install-wide hook token, such as a guest in another environment. Only the token's sha256 is stored. One
* active grant per node; a revoked grant keeps its row so a later post is refused as revoked, not unknown.
*/
export const activityGrantsSchema: Migration = {
name: "143_activity_grants.sql",
sql: `
CREATE TABLE activity_grants (
grant_id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
generation TEXT NOT NULL,
token_sha256 TEXT NOT NULL UNIQUE,
issued_by TEXT NOT NULL,
issued_at TEXT NOT NULL,
revoked_at TEXT,
revoke_reason TEXT
);
CREATE INDEX idx_activity_grants_active ON activity_grants(node_id) WHERE revoked_at IS NULL;
`,
};
@@ -0,0 +1,32 @@
import type { Migration } from "../migrate.js";
/** Placement provenance and effect receipts, never a second work queue or secret store. */
export const environmentBindingsSchema: Migration = {
name: "144_environment_bindings.sql",
sql: `
CREATE TABLE environment_bindings (
binding_id TEXT PRIMARY KEY,
version INTEGER NOT NULL CHECK(version = 1),
node_id TEXT REFERENCES nodes(id),
seat TEXT NOT NULL,
ownership TEXT NOT NULL CHECK(ownership IN ('owned', 'attached')),
config_json TEXT NOT NULL,
resource_json TEXT,
projection_json TEXT,
generation TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX environment_binding_node ON environment_bindings(node_id) WHERE node_id IS NOT NULL;
CREATE UNIQUE INDEX environment_binding_seat ON environment_bindings(seat);
CREATE TABLE environment_operations (
operation_id TEXT PRIMARY KEY,
binding_id TEXT NOT NULL REFERENCES environment_bindings(binding_id),
kind TEXT NOT NULL,
request_json TEXT NOT NULL,
effect_id TEXT,
state TEXT NOT NULL CHECK(state IN ('intent', 'requested', 'confirmed', 'refused')),
receipt_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`,
};
@@ -0,0 +1,128 @@
// A seat activity grant: the authority a workload outside the host's own hook setup, such as
// a guest in another environment or a runner inside one, uses to post activity for ONE seat and ONE launch generation.
// The install-wide hook token (activity-endpoint.ts) speaks for every seat, so it never goes into a guest; a grant goes
// there instead, under the same variable name, so runners need no change. The ingress (routes/activity.ts) resolves
// the grant first and only then accepts the seat and generation a post claims.
import type Database from "better-sqlite3";
import { createHash, randomBytes } from "node:crypto";
import { monotonicFactory } from "ulid";
const ulid = monotonicFactory();
const TOKEN_PREFIX = "oag_";
export type ActivityGrant = {
grantId: string;
nodeId: string;
generation: string;
issuedAt: string;
issuedBy: string;
revokedAt: string | null;
revokeReason: string | null;
};
export class ActivityGrantError extends Error {
constructor(readonly code: "node_not_found" | "invalid_grant_input", message: string) {
super(message);
}
}
type Row = { grant_id: string; node_id: string; generation: string; issued_at: string; issued_by: string;
revoked_at: string | null; revoke_reason: string | null };
const sha256 = (token: string): string => createHash("sha256").update(token).digest("hex");
function toGrant(row: Row): ActivityGrant {
return { grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
issuedBy: row.issued_by, revokedAt: row.revoked_at, revokeReason: row.revoke_reason };
}
/** A daemon whose database predates migration 143 has no grants, so every grant token is unknown there. */
export function hasActivityGrantsTable(db: Database.Database): boolean {
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'activity_grants'").get());
}
export class ActivityGrantStore {
constructor(private readonly db: Database.Database, private readonly now: () => Date = () => new Date()) {}
/** Issue the node's grant for `generation`: the occupant generation its launch reserved, which may not be registered
* yet. One active grant per node, so this revokes any earlier one ("reissued"): a lost response is reconciled by
* issuing again. The token is returned once; only its sha256 is stored. */
issue(input: { nodeId: string; generation: string; issuedBy: string }): ActivityGrant & { token: string } {
if (!input.nodeId.trim() || !input.generation.trim() || !input.issuedBy.trim()) {
throw new ActivityGrantError("invalid_grant_input", "An activity grant needs a node, a launch generation and who issued it.");
}
const token = `${TOKEN_PREFIX}${randomBytes(32).toString("hex")}`;
const grantId = `agrant-${ulid()}`;
const issuedAt = this.now().toISOString();
this.db.transaction(() => {
if (!this.db.prepare("SELECT 1 FROM nodes WHERE id = ?").get(input.nodeId)) {
throw new ActivityGrantError("node_not_found", `No seat with node ${input.nodeId}.`);
}
this.revokeActive(input.nodeId, "reissued", issuedAt);
this.db.prepare(`INSERT INTO activity_grants (grant_id, node_id, generation, token_sha256, issued_by, issued_at)
VALUES (?, ?, ?, ?, ?, ?)`).run(grantId, input.nodeId, input.generation, sha256(token), input.issuedBy, issuedAt);
})();
return { ...this.get(grantId)!, token };
}
/** Revoke one grant, or the node's active grant. Returns how many were revoked; revoking again changes nothing. */
revoke(target: { grantId: string } | { nodeId: string }, reason: string): number {
const at = this.now().toISOString();
if ("nodeId" in target) return this.revokeActive(target.nodeId, reason, at);
return this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = ? WHERE grant_id = ? AND revoked_at IS NULL")
.run(at, reason, target.grantId).changes;
}
get(grantId: string): ActivityGrant | null {
const row = this.db.prepare("SELECT * FROM activity_grants WHERE grant_id = ?").get(grantId) as Row | undefined;
return row ? toGrant(row) : null;
}
activeForNode(nodeId: string): ActivityGrant | null {
const row = this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
return row ? toGrant(row) : null;
}
/** The grant a presented token belongs to, revoked or not, or null for any other token. */
authenticate(token: string | null | undefined): ActivityGrant | null {
if (!token?.startsWith(TOKEN_PREFIX) || !hasActivityGrantsTable(this.db)) return null;
const row = this.db.prepare("SELECT * FROM activity_grants WHERE token_sha256 = ?").get(sha256(token)) as Row | undefined;
return row ? toGrant(row) : null;
}
private revokeActive(nodeId: string, reason: string, at: string): number {
return this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = ? WHERE node_id = ? AND revoked_at IS NULL")
.run(at, reason, nodeId).changes;
}
}
/** Where a grant stands against the node's occupant ledger: `current` speaks for the seat; `stale` names an occupant
* that has been replaced; `pending` names a reserved generation its launch hasn't registered yet. */
export function grantGeneration(grant: ActivityGrant, ledger: {
current(nodeId: string): string | null;
registered(nodeId: string, generation: string): boolean;
}): "current" | "stale" | "pending" {
if (ledger.current(grant.nodeId) === grant.generation) return "current";
return ledger.registered(grant.nodeId, grant.generation) ? "stale" : "pending";
}
/** The only activity values a guest receives. Build a guest's environment from an allow-list that includes these, never
* by copying the host's: the host carries the install-wide token. `guestBaseUrl` is the provider's mapping of the
* daemon's listener into the guest: an http(s) origin, with no credentials, path, query or fragment. */
export function guestActivityEnv(grant: { token: string; nodeId: string; generation: string }, sessionName: string, guestBaseUrl: string): {
OPENRIG_URL: string; OPENRIG_ACTIVITY_HOOK_TOKEN: string; OPENRIG_NODE_ID: string; OPENRIG_SESSION_NAME: string; OPENRIG_OCCUPANT_GENERATION: string;
} {
let url: URL;
try {
url = new URL(guestBaseUrl);
} catch {
throw new ActivityGrantError("invalid_grant_input", `The guest's activity address isn't a URL: ${guestBaseUrl}`);
}
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password
|| url.pathname !== "/" || url.search || url.hash) {
throw new ActivityGrantError("invalid_grant_input", "The guest's activity address must be an http(s) origin, such as http://host.example:7433.");
}
return { OPENRIG_URL: url.origin, OPENRIG_ACTIVITY_HOOK_TOKEN: grant.token, OPENRIG_NODE_ID: grant.nodeId,
OPENRIG_SESSION_NAME: sessionName, OPENRIG_OCCUPANT_GENERATION: grant.generation };
}
@@ -0,0 +1,114 @@
import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, rmSync } from "node:fs";
import { join, posix, resolve } from "node:path";
import type { QueueRepository } from "./queue-repository.js";
import type { QueueUpdateInput } from "./queue-repository.js";
import type { PersistedEvent } from "./types.js";
import { EnvironmentError, EnvironmentPlatform, identifier, regularBytes, relativeGuestPath, sha256 } from "./environment-platform.js";
export type ArtifactSchema = { format: "json-object/v1"; fields: Record<string, "string" | "number" | "boolean">; required: string[] };
export type EnvironmentArtifactRequest = { operationId: string; taskId: string; generation: string; path: string;
sha256: string; bytes: number; schema: ArtifactSchema; context: Record<string, string | number | boolean> };
export type ArtifactCaller = { actor: string; identityProvenance: QueueUpdateInput["identityProvenance"]; identityPrincipal: string | null };
export type EnvironmentArtifactReceipt = { version: 1; operationId: string; bindingId: string; nodeId: string;
generation: string; taskId: string; path: string; guestPath: string; sha256: string; bytes: number; actor: string;
identityProvenance: QueueUpdateInput["identityProvenance"]; identityPrincipal: string | null };
const stable = (v: unknown): string => JSON.stringify(v, (_key, x) => x && typeof x === "object" && !Array.isArray(x)
? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b))) : x);
function refuse(code: string, message: string, status = 409): never { throw new EnvironmentError(code, message, status); }
/** Transfers and verifies actual bytes; publication and the existing work note reconcile on the same operation. */
export class EnvironmentArtifacts {
constructor(private readonly platform: EnvironmentPlatform, private readonly queue: QueueRepository, private readonly root: string) {}
private contract(input: EnvironmentArtifactRequest): void {
identifier(input.operationId, "operationId"); identifier(input.taskId, "taskId"); identifier(input.generation, "generation");
relativeGuestPath(input.path);
if (!/^[0-9a-f]{64}$/.test(input.sha256) || !Number.isSafeInteger(input.bytes) || input.bytes < 1 || input.bytes > 1024 * 1024)
refuse("invalid_artifact_contract", "Expected SHA256 and byte count (1 through 1048576) are required", 400);
const schema = input.schema;
if (!schema || schema.format !== "json-object/v1" || !schema.fields || Array.isArray(schema.fields)
|| !Array.isArray(schema.required) || Object.keys(schema.fields).length > 100
|| !Object.values(schema.fields).every(v => ["string", "number", "boolean"].includes(v))
|| !schema.required.every(v => typeof v === "string" && Object.hasOwn(schema.fields, v)))
refuse("invalid_artifact_schema", "A supported json-object/v1 artifact contract is required", 400);
if (!input.context || typeof input.context !== "object" || Array.isArray(input.context)
|| Object.keys(input.context).length > 100 || !Object.entries(input.context).every(([key, v]) =>
Object.hasOwn(schema.fields, key) && ["string", "number", "boolean"].includes(typeof v)))
refuse("invalid_artifact_context", "Context must select declared scalar fields", 400);
}
private validate(data: Buffer, input: EnvironmentArtifactRequest): void {
if (data.length !== input.bytes || sha256(data) !== input.sha256) refuse("artifact_bytes_changed", "Returned bytes do not match the selected artifact");
let body: Record<string, unknown>;
try { body = JSON.parse(data.toString("utf8")); } catch { refuse("artifact_schema_mismatch", "The artifact is not JSON"); }
if (!body! || typeof body! !== "object" || Array.isArray(body!)) refuse("artifact_schema_mismatch", "The artifact must be a JSON object");
for (const key of input.schema.required) if (!Object.hasOwn(body!, key)) refuse("artifact_schema_mismatch", `Required artifact field ${key} is missing`);
for (const [key, type] of Object.entries(input.schema.fields))
if (Object.hasOwn(body!, key) && typeof body![key] !== type) refuse("artifact_schema_mismatch", `Artifact field ${key} has the wrong type`);
for (const [key, value] of Object.entries(input.context)) if (body![key] !== value) refuse("artifact_context_mismatch", `Artifact field ${key} does not match the selected work context`);
}
async export(bindingId: string, input: EnvironmentArtifactRequest, caller: ArtifactCaller): Promise<EnvironmentArtifactReceipt> {
this.contract(input);
const binding = this.platform.get(bindingId);
if (!binding?.resource || !binding.nodeId) refuse("environment_binding_missing", "Export requires a confirmed node binding", 404);
if (binding.generation !== input.generation) refuse("environment_generation_changed", "This export does not belong to the selected launch generation");
const task = this.queue.getById(input.taskId);
if (!task || task.destinationSession !== binding.seat) refuse("artifact_task_mismatch", "The selected work row is not owned by this bound seat");
if (caller.actor !== task.destinationSession && caller.actor !== task.sourceSession) refuse("artifact_caller_mismatch", "Only the work owner or its assigning caller may request its export", 403);
const db = this.platform.db;
const request = stable(input);
db.transaction(() => {
const old = db.prepare("SELECT binding_id, kind, request_json FROM environment_operations WHERE operation_id = ?").get(input.operationId) as Record<string, string> | undefined;
if (old && (old.binding_id !== bindingId || old.kind !== "export" || old.request_json !== request)) refuse("environment_operation_changed", "The export operation input changed");
if (!old) db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state) VALUES (?,?,'export',?,'requested')")
.run(input.operationId, bindingId, request);
})();
const targetDir = join(resolve(binding.artifactRoot ?? this.root), bindingId);
let current = targetDir;
while (current !== resolve(current, "..")) {
if (existsSync(current) && lstatSync(current).isSymbolicLink()) refuse("artifact_publication_conflict", "The publication root contains a symlink");
current = resolve(current, "..");
}
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
const unchanged = () => {
const now = this.platform.get(bindingId), taskNow = this.queue.getById(input.taskId);
if (now?.generation !== input.generation || now.resource?.id !== binding.resource!.id || now.nodeId !== binding.nodeId || taskNow?.destinationSession !== binding.seat)
refuse("environment_selection_changed", "The binding or work owner changed during export");
};
const target = join(targetDir, `${input.taskId}-${input.operationId}.json`);
// Reconcile existing validated host bytes BEFORE touching a guest that may already have exited.
if (existsSync(target) || (() => { try { return lstatSync(target).isSymbolicLink(); } catch { return false; } })()) this.validate(regularBytes(target), input);
else {
const stage = mkdtempSync(join(targetDir, ".transfer-"));
try {
await this.platform.current(bindingId, true);
const staged = join(stage, "artifact.json");
await this.platform.driver.download(binding, posix.join(binding.guestRoot, "work/artifacts", input.path), staged);
this.validate(regularBytes(staged), input);
unchanged();
const fd = openSync(staged, "r"); try { fsyncSync(fd); } finally { closeSync(fd); }
try { linkSync(staged, target); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; this.validate(regularBytes(target), input); }
} finally { rmSync(stage, { recursive: true }); }
}
this.validate(regularBytes(target), input);
unchanged();
const directory = openSync(targetDir, "r"); try { fsyncSync(directory); } finally { closeSync(directory); }
const receipt: EnvironmentArtifactReceipt = { version: 1, operationId: input.operationId, bindingId, nodeId: binding.nodeId,
generation: input.generation, taskId: input.taskId, path: target, guestPath: posix.join(binding.guestRoot, "work/artifacts", input.path),
sha256: input.sha256, bytes: input.bytes, actor: caller.actor, identityProvenance: caller.identityProvenance, identityPrincipal: caller.identityPrincipal };
let events: PersistedEvent[] = [];
const saved = db.transaction(() => {
unchanged();
const old = db.prepare("SELECT receipt_json FROM environment_operations WHERE operation_id = ?").get(input.operationId) as { receipt_json: string | null };
if (old.receipt_json) return JSON.parse(old.receipt_json) as EnvironmentArtifactReceipt;
const note = `Environment artifact ${input.operationId}: ${JSON.stringify(receipt)}`;
// Only the receipt/note changes; never close the child's work on behalf of its owner.
const result = this.queue.updateWithinTransaction({ qitemId: input.taskId, actorSession: caller.actor, transitionNote: note,
identityProvenance: caller.identityProvenance, identityPrincipal: caller.identityPrincipal });
events = result.persistedEvents;
db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), input.operationId);
return receipt;
})();
this.queue.notifyCommitted(events);
return saved;
}
}
@@ -0,0 +1,230 @@
import type Database from "better-sqlite3";
import { createHash, randomUUID } from "node:crypto";
import { lstatSync, readFileSync } from "node:fs";
import { isAbsolute, posix, resolve } from "node:path";
export const ENVIRONMENT_DRIVER_VERSION = 1;
export type EnvironmentResource = { id: string; name: string; workspace: string; fingerprint?: string; phase: string; operationId?: string };
export type Placement = { gateway: string; workspace: string; name: string; image: string; provider: string; policy: string; policySha256?: string };
export type EnvironmentConfig = {
version: 1; bindingId: string; nodeId?: string; seat: string; coordinationRef?: string; artifactRoot?: string; baseRuntime: "claude-code";
guestRoot: string; guestBaseUrl: string; placement: Placement;
};
export type EnvironmentBinding = EnvironmentConfig & {
ownership: "owned" | "attached"; resource: EnvironmentResource | null; generation: string | null;
};
export type ProjectionFile = { source: string; path: string; sha256: string; required: boolean; transient?: boolean; sourceIdentity?: { path: string; sha256: string } };
export type ProjectionReceipt = { path: string; sha256: string; bytes: number; required: boolean; sourceFile?: string; sourceSha256?: string };
/** Version 1 trusted driver boundary. Providers own calls/path mapping; Core owns identity, intent and receipts. */
export interface EnvironmentDriver {
readonly version: 1;
list(placement: Placement): Promise<{ complete: boolean; resources: EnvironmentResource[] }>;
create(input: { operationId: string; placement: Placement }): Promise<EnvironmentResource>;
inspect(binding: EnvironmentBinding): Promise<EnvironmentResource>;
upload(binding: EnvironmentBinding, source: string, destination: string, previousHash?: string, executable?: boolean): Promise<void>;
read(binding: EnvironmentBinding, path: string): Promise<Buffer>;
download(binding: EnvironmentBinding, source: string, destination: string): Promise<void>;
}
export class EnvironmentError extends Error {
constructor(readonly code: string, message: string, readonly status = 409) { super(message); }
}
export const sha256 = (data: Buffer | string): string => createHash("sha256").update(data).digest("hex");
function fail(code: string, message: string, status = 409): never { throw new EnvironmentError(code, message, status); }
export function identifier(value: unknown, name: string): string {
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value)) fail("invalid_environment_request", `${name} must be a short identifier`, 400);
return value;
}
export function relativeGuestPath(value: unknown): string {
if (typeof value !== "string" || !value || value.includes("\\") || value.includes("\0") || posix.isAbsolute(value)
|| value.split("/").some(p => !p || p === "." || p === "..")) fail("invalid_environment_path", "A relative guest path without traversal is required", 400);
return value;
}
export function regularBytes(file: string, limit = 1024 * 1024): Buffer {
if (!isAbsolute(file)) fail("invalid_environment_path", "An absolute host file path is required", 400);
let current = resolve(file);
while (current !== resolve(current, "..")) {
if (lstatSync(current).isSymbolicLink()) fail("environment_symlink", "Symlink components are not accepted");
current = resolve(current, "..");
}
const stat = lstatSync(file);
if (!stat.isFile() || stat.size > limit) fail("invalid_environment_file", "A bounded regular file is required");
const data = readFileSync(file);
if (data.length !== stat.size || data.length > limit) fail("environment_file_changed", "The file changed while reading");
return data;
}
const stable = (value: unknown): string => JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v);
export class EnvironmentPlatform {
readonly protocolVersion = 1 as const;
constructor(readonly db: Database.Database, readonly driver: EnvironmentDriver) {
if (driver.version !== 1) fail("environment_driver_unsupported", "Environment driver protocol 1 is required");
}
get(id: string): EnvironmentBinding | null {
const row = this.db.prepare("SELECT * FROM environment_bindings WHERE binding_id = ?").get(id) as Record<string, any> | undefined;
return row ? { ...JSON.parse(row.config_json), nodeId: row.node_id ?? undefined, ownership: row.ownership,
resource: row.resource_json ? JSON.parse(row.resource_json) : null, generation: row.generation } : null;
}
forNode(nodeId: string, seat?: string): EnvironmentBinding | null {
const row = this.db.prepare("SELECT binding_id FROM environment_bindings WHERE node_id = ? OR (node_id IS NULL AND seat = ?)")
.get(nodeId, seat ?? "") as { binding_id: string } | undefined;
return row ? this.get(row.binding_id) : null;
}
private config(input: EnvironmentConfig): EnvironmentConfig {
if (!input || input.version !== 1 || input.baseRuntime !== "claude-code") fail("invalid_environment_request", "Version 1 and baseRuntime claude-code are required", 400);
identifier(input.bindingId, "bindingId");
if (typeof input.seat !== "string" || !/^[^\s@]+@[^\s@]+$/.test(input.seat)) fail("invalid_environment_request", "A canonical seat address is required", 400);
if (input.guestRoot !== "/sandbox/openrig") fail("invalid_environment_path", "A normalized absolute guest root is required", 400);
let url: URL; try { url = new URL(input.guestBaseUrl); } catch { fail("invalid_environment_request", "A guest-reachable daemon origin is required", 400); }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
fail("invalid_environment_request", "The guest endpoint must be an HTTP(S) origin without credentials", 400);
const place = input.placement;
if (!place || !/^[a-z0-9](?:[a-z0-9-]{0,17}[a-z0-9])?$/.test(place.name)
|| !/^.+@sha256:[0-9a-f]{64}$/.test(place.image)) fail("invalid_environment_request", "An OpenShell name and digest-pinned image are required", 400);
for (const key of ["gateway", "workspace", "provider"] as const)
if (typeof place[key] !== "string" || !place[key] || /[\s\0]/.test(place[key])) fail("invalid_environment_request", `placement.${key} is required`, 400);
const policySha256 = sha256(regularBytes(place.policy));
if (input.nodeId && !this.db.prepare("SELECT id FROM nodes WHERE id = ?").get(input.nodeId)) fail("environment_node_missing", "The selected node is missing", 404);
return { version: 1, bindingId: input.bindingId, ...(input.nodeId ? { nodeId: input.nodeId } : {}), seat: input.seat,
...(typeof input.coordinationRef === "string" ? { coordinationRef: input.coordinationRef } : {}),
...(typeof input.artifactRoot === "string" && isAbsolute(input.artifactRoot) ? { artifactRoot: resolve(input.artifactRoot) } : {}),
baseRuntime: input.baseRuntime, guestRoot: input.guestRoot, guestBaseUrl: url.origin,
placement: { gateway: place.gateway, workspace: place.workspace, name: place.name, image: place.image, provider: place.provider, policy: place.policy, policySha256 } };
}
private intent(id: string, kind: string, binding: EnvironmentConfig, ownership: "owned" | "attached", request: unknown): { fresh: boolean; state: string; effectId: string; receipt: any } {
identifier(id, "operationId");
return this.db.transaction(() => {
const prior = this.db.prepare("SELECT * FROM environment_operations WHERE operation_id = ?").get(id) as Record<string, any> | undefined;
if (prior) {
if (prior.binding_id !== binding.bindingId || prior.kind !== kind || prior.request_json !== stable(request))
fail("environment_operation_changed", "An operation identity cannot be reused with different input");
return { fresh: false, state: prior.state, effectId: prior.effect_id, receipt: prior.receipt_json ? JSON.parse(prior.receipt_json) : null };
}
const existing = this.get(binding.bindingId);
if (existing) {
const protectedOperation = this.db.prepare("SELECT 1 FROM environment_operations WHERE binding_id = ? AND state <> 'intent'").get(binding.bindingId);
if (existing.resource || protectedOperation) fail("environment_binding_exists", "The existing binding has a provider effect; reconcile it before replacement");
this.db.prepare("DELETE FROM environment_operations WHERE binding_id = ?").run(binding.bindingId);
this.db.prepare("DELETE FROM environment_bindings WHERE binding_id = ?").run(binding.bindingId);
}
if (this.db.prepare("SELECT 1 FROM environment_bindings WHERE seat = ? OR (node_id IS NOT NULL AND node_id = ?)").get(binding.seat, binding.nodeId ?? null))
fail("environment_seat_bound", "This stable seat or node already has another binding");
this.db.prepare("INSERT INTO environment_bindings(binding_id,version,node_id,seat,ownership,config_json) VALUES (?,1,?,?,?,?)")
.run(binding.bindingId, binding.nodeId ?? null, binding.seat, ownership, JSON.stringify(binding));
const effectId = `env-${randomUUID()}`;
this.db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,effect_id,state) VALUES (?,?,?,?,?,'intent')")
.run(id, binding.bindingId, kind, stable(request), effectId);
return { fresh: true, state: "intent", effectId, receipt: null };
})();
}
private confirm(operationId: string, bindingId: string, resource: EnvironmentResource): EnvironmentBinding {
this.db.transaction(() => {
this.db.prepare("UPDATE environment_bindings SET resource_json = ? WHERE binding_id = ?").run(JSON.stringify(resource), bindingId);
this.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(resource), operationId);
})();
return this.get(bindingId)!;
}
async create(input: EnvironmentConfig & { operationId: string }): Promise<{ status: number; binding: EnvironmentBinding | null; code?: string }> {
const config = this.config(input), op = this.intent(input.operationId, "create", config, "owned", config);
if (op.state === "confirmed") return { status: 200, binding: this.get(config.bindingId) };
let listed: Awaited<ReturnType<EnvironmentDriver["list"]>>;
try { listed = await this.driver.list(config.placement); } catch { return { status: 202, binding: this.get(config.bindingId), code: "environment_unconfirmed" }; }
if (!listed.complete) return { status: 202, binding: this.get(config.bindingId), code: "environment_listing_incomplete" };
const matches = listed.resources.filter(r => r.operationId === op.effectId);
if (matches.length > 1) fail("environment_duplicate_resources", "More than one resource matches the saved create operation");
if (matches.length === 1) {
const found = matches[0]!;
if (found.name !== config.placement.name || found.workspace !== config.placement.workspace || !found.id) fail("environment_identity_changed", "The operation resource does not match the selected placement");
return { status: 200, binding: this.confirm(input.operationId, config.bindingId, found) };
}
// A complete empty listing after a requested effect is still not permission to repeat an asynchronous create.
if (op.state !== "intent") return { status: 202, binding: this.get(config.bindingId), code: "environment_create_unconfirmed" };
if (listed.resources.some(r => r.name === config.placement.name)) fail("environment_name_in_use", "The selected name already belongs to another resource");
const claimed = this.db.prepare("UPDATE environment_operations SET state = 'requested' WHERE operation_id = ? AND state = 'intent'").run(input.operationId);
if (!claimed.changes) return { status: 202, binding: this.get(config.bindingId), code: "environment_create_unconfirmed" };
try {
const resource = await this.driver.create({ operationId: op.effectId, placement: config.placement });
if (!resource.id || resource.name !== config.placement.name || resource.workspace !== config.placement.workspace || resource.operationId !== op.effectId)
return { status: 202, binding: this.get(config.bindingId), code: "environment_create_unconfirmed" };
return { status: 201, binding: this.confirm(input.operationId, config.bindingId, resource) };
} catch { return { status: 202, binding: this.get(config.bindingId), code: "environment_create_unconfirmed" }; }
}
async attach(input: EnvironmentConfig & { operationId: string; resourceId: string; fingerprint?: string }): Promise<{ status: number; binding: EnvironmentBinding }> {
const config = this.config(input); identifier(input.operationId, "operationId");
if (typeof input.resourceId !== "string" || !input.resourceId) fail("invalid_environment_request", "An immutable resource ID is required", 400);
const candidate = { ...config, ownership: "attached" as const, resource: { id: input.resourceId, name: config.placement.name,
workspace: config.placement.workspace, fingerprint: input.fingerprint, phase: "unknown" }, generation: null };
const observed = await this.driver.inspect(candidate);
this.assertResource(candidate, observed);
const op = this.intent(input.operationId, "attach", config, "attached", { config, resourceId: input.resourceId, fingerprint: input.fingerprint });
return { status: op.state === "confirmed" ? 200 : 201, binding: this.confirm(input.operationId, config.bindingId, observed) };
}
assertResource(binding: EnvironmentBinding, resource: EnvironmentResource, ready = false): void {
const expected = binding.resource;
if (!expected || resource.id !== expected.id || resource.name !== expected.name || resource.workspace !== expected.workspace
|| expected.fingerprint && resource.fingerprint !== expected.fingerprint) fail("environment_identity_changed", "The provider resource no longer matches the immutable binding");
if (ready && resource.phase !== "Ready") fail("environment_not_ready", "Projection and export require the bound environment to be Ready");
}
async current(id: string, ready = false): Promise<EnvironmentBinding> {
const binding = this.get(id); if (!binding) fail("environment_binding_missing", "No selected environment binding", 404);
this.assertResource(binding, await this.driver.inspect(binding), ready); return binding;
}
async project(id: string, input: { operationId: string; generation: string; files: ProjectionFile[] }): Promise<{ version: 1; bindingId: string; generation: string; files: ProjectionReceipt[] }> {
identifier(input.operationId, "operationId"); identifier(input.generation, "generation");
if (!Array.isArray(input.files) || input.files.length > 4096) fail("invalid_environment_request", "A bounded explicit file list is required", 400);
const binding = await this.current(id, true);
const planned = input.files.map(file => {
const path = relativeGuestPath(file.path), data = regularBytes(file.source);
if (!/^[0-9a-f]{64}$/.test(file.sha256) || sha256(data) !== file.sha256) fail("environment_projection_changed", "A selected source file changed");
return { file, path, data };
});
if (planned.reduce((bytes, item) => bytes + item.data.length, 0) > 32 * 1024 * 1024) fail("environment_projection_too_large", "The selected projection exceeds 32 MiB");
if (new Set(planned.map(p => p.path)).size !== planned.length) fail("invalid_environment_request", "Duplicate projection destinations", 400);
const saved = this.db.prepare("SELECT projection_json FROM environment_bindings WHERE binding_id = ?").get(id) as { projection_json: string | null };
const previous = new Map<string, ProjectionReceipt>((saved.projection_json ? JSON.parse(saved.projection_json).files : []).map((f: ProjectionReceipt) => [f.path, f]));
const changedPaths = new Set(planned.map(item => item.path));
const retained = [...previous.values()].filter(file => !changedPaths.has(file.path));
if (retained.length + planned.length > 4096 || retained.reduce((sum, file) => sum + file.bytes, 0) + planned.reduce((sum, item) => sum + item.data.length, 0) > 32 * 1024 * 1024)
fail("environment_projection_too_large", "The combined destination projection exceeds its file or byte cap");
const receipt: ProjectionReceipt[] = [];
for (const { file, path, data } of planned) {
const destination = posix.join(binding.guestRoot, path);
this.assertResource(binding, await this.driver.inspect(binding), true);
let existing: Buffer | undefined;
try { existing = await this.driver.read(binding, destination); } catch (error) {
if (!(error instanceof EnvironmentError && error.code === "environment_file_missing")) throw error;
}
if (existing && sha256(existing) !== file.sha256 && sha256(existing) !== previous.get(path)?.sha256)
fail("environment_projection_conflict", "An unmanaged or changed destination file was preserved");
if (!existing || sha256(existing) !== file.sha256) await this.driver.upload(binding, file.source, destination, existing ? sha256(existing) : undefined, Boolean(lstatSync(file.source).mode & 0o111));
const actual = await this.driver.read(binding, destination);
if (!actual.equals(data)) fail("environment_projection_changed", "The destination bytes do not match the resolved source");
receipt.push({ path, sha256: file.sha256, bytes: data.length, required: file.required,
...(!file.transient ? { sourceFile: file.sourceIdentity?.path ?? file.source, sourceSha256: file.sourceIdentity?.sha256 ?? file.sha256 } : {}) });
}
const result = { version: 1 as const, bindingId: id, generation: input.generation,
files: retained.concat(receipt) };
this.db.prepare("UPDATE environment_bindings SET projection_json = ?, generation = ? WHERE binding_id = ?")
.run(JSON.stringify(result), input.generation, id);
return result;
}
async validate(id: string, generation: string): Promise<void> {
const binding = await this.current(id, true);
if (binding.generation !== generation) fail("environment_generation_changed", "The prepared environment belongs to another launch generation");
const row = this.db.prepare("SELECT projection_json FROM environment_bindings WHERE binding_id = ?").get(id) as { projection_json: string | null };
if (!row.projection_json) fail("environment_projection_missing", "No validated destination projection is available");
for (const file of JSON.parse(row.projection_json).files as ProjectionReceipt[]) {
try {
if (file.sourceFile && sha256(regularBytes(file.sourceFile)) !== file.sourceSha256) fail("environment_projection_source_changed", "A resolved projection source changed before launch");
const data = await this.driver.read(binding, posix.join(binding.guestRoot, file.path));
if (sha256(data) !== file.sha256 || data.length !== file.bytes) fail("environment_projection_changed", "A prepared destination file changed before launch");
} catch (error) { if (file.required) throw error; }
}
}
effect(id: string, effect: string): never {
const binding = this.get(id); if (!binding) fail("environment_binding_missing", "No environment binding", 404);
if (binding.ownership === "attached") fail("environment_attached_preserved", "Attached native, storage and compute are preserved");
fail("environment_effect_unsupported", `Effect ${effect} is not implemented by this version 1 driver`, 501);
}
}
@@ -0,0 +1,173 @@
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { join, posix } from "node:path";
import { randomUUID } from "node:crypto";
import type { ProjectionPlan } from "./projection-planner.js";
import type { NodeBinding, ProjectionResult, ResolvedStartupFile, StartupDeliveryResult, HarnessLaunchResult } from "./runtime-adapter.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { shellQuote } from "../adapters/shell-quote.js";
import { EnvironmentPlatform, EnvironmentError, regularBytes, sha256, type ProjectionFile, type ProjectionReceipt } from "./environment-platform.js";
import { OpenShellDriver } from "../adapters/openshell-driver.js";
export type EnvironmentActivity = (input: { nodeId: string; generation: string; seat: string; guestBaseUrl: string }) =>
Promise<{ grantId: string; env: Record<string, string> }>;
/** One maintained consumer of RuntimeAdapter's optional environment operations; no host fallback when selected. */
export class EnvironmentStartup {
constructor(readonly platform: EnvironmentPlatform, private readonly stageRoot: string, private readonly activity: EnvironmentActivity, private readonly checkActivityEndpoint?: (origin: string) => void) {}
private selected(binding: NodeBinding) {
const selected = this.platform.forNode(binding.nodeId, binding.tmuxSession ?? undefined);
if (!selected || !selected.resource) throw new EnvironmentError("environment_binding_missing", "Select create or attach for this seat before starting its contained runtime");
if (selected.nodeId && selected.nodeId !== binding.nodeId || selected.seat !== binding.tmuxSession)
throw new EnvironmentError("environment_seat_changed", "The selected environment belongs to a different stable seat");
if (!binding.launchGeneration) throw new EnvironmentError("environment_generation_missing", "An environment launch requires a reserved occupant generation");
return { ...selected, nodeId: binding.nodeId };
}
async preflight(binding: NodeBinding, opts: { resumeToken?: string; forkSource?: unknown } = {}): Promise<void> {
if (opts.resumeToken || opts.forkSource) throw new EnvironmentError("environment_restore_unsupported", "Native restore/fork is unsupported in v1; no guest input was replaced");
this.preparation(binding);
}
private preparation(binding: NodeBinding) {
const selected = this.selected(binding);
// Check all saved launch states before claiming the binding or changing any guest input.
const prior = this.platform.db.prepare("SELECT operation_id FROM environment_operations WHERE binding_id = ? AND kind = 'launch'").get(selected.bindingId);
if (prior) throw new EnvironmentError("environment_prior_execution_unconfirmed", "A saved native execution must be reconciled before projection; its generation and files were preserved");
this.checkActivityEndpoint?.(selected.guestBaseUrl);
const saved = this.platform.get(selected.bindingId)!;
if (!saved.nodeId) {
const claimed = this.platform.db.prepare("UPDATE environment_bindings SET node_id = ? WHERE binding_id = ? AND node_id IS NULL").run(binding.nodeId, selected.bindingId);
if (!claimed.changes) throw new EnvironmentError("environment_selection_changed", "The stable seat reservation changed before projection");
}
return selected;
}
private stage() { mkdirSync(this.stageRoot, { recursive: true, mode: 0o700 }); return mkdtempSync(join(this.stageRoot, "projection-")); }
private walk(source: string, destination: string, required: boolean, out: ProjectionFile[]): void {
const stat = lstatSync(source);
if (stat.isSymbolicLink()) throw new EnvironmentError("environment_projection_symlink", "A declared projection contains a symlink; select its regular destination explicitly");
if (stat.isDirectory()) {
for (const name of readdirSync(source).sort()) this.walk(join(source, name), posix.join(destination, name), required, out);
} else {
const bytes = regularBytes(source);
out.push({ source, path: destination, sha256: sha256(bytes), required });
}
}
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
const selected = this.preparation(binding), files: ProjectionFile[] = [];
const stage = this.stage();
try {
const guidance: string[] = [];
for (const entry of plan.entries) {
if (entry.classification === "operator_conflict" || entry.classification === "hash_conflict")
throw new EnvironmentError("environment_projection_conflict", `Resolve the selected projection conflict for ${entry.effectiveId}`);
const key = encodeURIComponent(entry.effectiveId);
const destination = entry.category === "skill" ? `work/.claude/skills/${key}`
: entry.category === "plugin" ? `work/.claude/plugins/${key}` : `work/.openrig/context/${key}`;
this.walk(realpathSync(entry.absolutePath), destination, true, files);
if (entry.category === "guidance") guidance.push(`Read ${posix.join(selected.guestRoot, destination)}.`);
if (entry.category === "runtime_resource" || entry.category === "subagent")
throw new EnvironmentError("environment_projection_unsupported", `The OpenShell Claude path does not yet map ${entry.category}; no cold launch was attempted`);
}
guidance.unshift("This execution is in OpenShell. Use the authenticated OpenRig MCP tools for team identity and queue work. Host-only CLI paths and endpoints are not guest paths.");
{
const local = join(stage, "CLAUDE.md"); writeFileSync(local, guidance.join("\n") + "\n", { mode: 0o600 });
files.push({ source: local, path: "work/CLAUDE.md", sha256: sha256(regularBytes(local)), required: true, transient: true });
}
await this.platform.project(selected.bindingId, { operationId: `projection-${binding.launchGeneration}`, generation: binding.launchGeneration!, files });
return { projected: plan.entries.map(e => e.effectiveId), skipped: [], failed: [] };
} finally { rmSync(stage, { recursive: true }); }
}
async deliverStartup(files: ResolvedStartupFile[], binding: NodeBinding): Promise<StartupDeliveryResult> {
const selected = this.preparation(binding), stage = this.stage();
try {
const row = this.platform.db.prepare("SELECT projection_json FROM environment_bindings WHERE binding_id = ?").get(selected.bindingId) as { projection_json: string | null };
const previous = row.projection_json ? JSON.parse(row.projection_json) : { files: [] };
const staged: ProjectionFile[] = [];
// Every applicable resolved input, including send_text inputs, is checked at the destination before native launch.
for (const file of files) {
const source = realpathSync(file.absolutePath), original = regularBytes(source);
const text = original.toString("utf8").split(binding.cwd).join(posix.join(selected.guestRoot, "work"));
const path = `work/.openrig/startup/${encodeURIComponent(file.path)}`;
const local = join(stage, `startup-${staged.length}`); writeFileSync(local, text, { mode: 0o600 });
staged.push({ source: local, path, sha256: sha256(text), required: file.required, sourceIdentity: { path: source, sha256: sha256(original) } });
}
const destinations = new Set<string>([...previous.files.filter((file: ProjectionReceipt) => file.path.startsWith("work/.openrig/")).map((file: ProjectionReceipt) => file.path), ...staged.map(file => file.path)]);
const guidance = "This execution is in OpenShell. Use the authenticated OpenRig MCP tools for team identity and queue work. Host-only CLI paths and endpoints are not guest paths.\n"
+ [...destinations].sort().map(path => `Read ${posix.join(selected.guestRoot, path)}.`).join("\n") + "\n";
const index = join(stage, "CLAUDE.md"); writeFileSync(index, guidance, { mode: 0o600 });
staged.push({ source: index, path: "work/CLAUDE.md", sha256: sha256(guidance), required: true, transient: true });
await this.platform.project(selected.bindingId, { operationId: `startup-${binding.launchGeneration}`, generation: binding.launchGeneration!, files: staged });
return { delivered: files.length, failed: [] };
} finally { rmSync(stage, { recursive: true }); }
}
async launch(binding: NodeBinding, opts: { resumeToken?: string; forkSource?: unknown; beforeNativeDispatch?: () => boolean }, tmux: Pick<TmuxAdapter, "sendShellCommand">): Promise<HarnessLaunchResult> {
if (opts.resumeToken || opts.forkSource) return { ok: false, recovery: "attention_required", error: "Environment native restore/fork is not implemented by driver version 1; no fresh conversation was started." };
const selected = this.selected(binding), generation = binding.launchGeneration!;
const operationId = `launch-${generation}`;
const prior = this.platform.db.prepare("SELECT binding_id, kind, state, request_json, receipt_json FROM environment_operations WHERE operation_id = ?").get(operationId) as { binding_id: string; kind: string; state: string; request_json: string; receipt_json: string | null } | undefined;
if (prior) {
const request = JSON.parse(prior.request_json);
if (prior.binding_id !== selected.bindingId || prior.kind !== "launch" || request.nodeId !== binding.nodeId || request.generation !== generation || request.resourceId !== selected.resource!.id)
throw new EnvironmentError("environment_operation_changed", "The saved native launch belongs to a different binding");
// Reconcile the original execution before issuing grants or changing any guest inputs.
// Intent is conservative too: a crash may have happened after any awaited write.
if (!(this.platform.driver instanceof OpenShellDriver)) throw new EnvironmentError("environment_native_unconfirmed", "The saved execution needs live identity reconciliation");
const observed = await this.platform.driver.guest(selected, ["--identity"]);
if (observed.nativeId !== request.nativeId || observed.generation !== generation)
throw new EnvironmentError("environment_native_unconfirmed", "The original native identity is unconfirmed; no grant or execution was replaced");
const receipt = { ...request, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: null };
this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId);
return { ok: true, resumeToken: request.nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher", warnings: ["The original native identity was reconciled; viewer readiness still requires its existing observation and no viewer input was replayed."] };
}
const older = this.platform.db.prepare("SELECT operation_id FROM environment_operations WHERE binding_id = ? AND kind = 'launch' AND operation_id <> ? AND state IN ('requested','confirmed')").get(selected.bindingId, operationId);
if (older) throw new EnvironmentError("environment_prior_execution_unconfirmed", "A prior native execution must be reconciled through lifecycle/restore before another launch");
await this.platform.validate(selected.bindingId, generation);
if (!selected.coordinationRef) throw new EnvironmentError("environment_coordination_missing", "Configure this binding's scoped MCP work connection before native launch");
const connection = JSON.parse(regularBytes(selected.coordinationRef).toString("utf8")) as { version?: number; mcpUrl?: string; seat?: string; token?: string };
if (connection.version !== 1 || connection.seat !== selected.seat || typeof connection.token !== "string" || !connection.token || typeof connection.mcpUrl !== "string")
throw new EnvironmentError("environment_coordination_invalid", "The managed work connection does not belong to this bound seat");
const coordination = Buffer.from(JSON.stringify({ mcpServers: { openrig: { type: "http", url: connection.mcpUrl, headers: { Authorization: `Bearer ${connection.token}` } } } }));
const row = this.platform.db.prepare("SELECT projection_json FROM environment_bindings WHERE binding_id = ?").get(selected.bindingId) as { projection_json: string };
const files = JSON.parse(row.projection_json).files as ProjectionReceipt[];
const nativeId = randomUUID();
const request = { version: 1, bindingId: selected.bindingId, nodeId: binding.nodeId, generation, resourceId: selected.resource!.id, nativeId };
this.platform.db.prepare("INSERT OR IGNORE INTO environment_operations(operation_id,binding_id,kind,request_json,state) VALUES (?,?,'launch',?,'intent')")
.run(operationId, selected.bindingId, JSON.stringify(request));
const stage = this.stage();
try {
const activity = await this.activity({ nodeId: binding.nodeId, generation, seat: selected.seat, guestBaseUrl: selected.guestBaseUrl });
const putPrivate = async (name: string, data: string) => {
const local = join(stage, name); writeFileSync(local, data, { mode: 0o600 });
const destination = posix.join(selected.guestRoot, "control", name);
let previous: string | undefined;
try { previous = sha256(await this.platform.driver.read(selected, destination)); }
catch (error) { if (!(error instanceof EnvironmentError && error.code === "environment_file_missing")) throw error; }
await this.platform.driver.upload(selected, local, destination, previous);
};
this.platform.db.prepare("UPDATE environment_operations SET request_json = ? WHERE operation_id = ? AND state = 'intent'").run(JSON.stringify({ ...request, activityGrantId: activity.grantId }), operationId);
await putPrivate("activity.json", JSON.stringify(activity.env));
await putPrivate("coordination.json", coordination.toString("utf8"));
const descriptor = { ...request, files, model: binding.model, effort: binding.effort, permissionMode: binding.permissionMode,
plugins: files.filter(file => file.path.includes("/.claude/plugins/") && file.path.endsWith("/.claude-plugin/plugin.json"))
.map(file => posix.join(selected.guestRoot, file.path.slice(0, -"/.claude-plugin/plugin.json".length))) };
await putPrivate("launch.pending.json", JSON.stringify(descriptor));
await this.platform.validate(selected.bindingId, generation);
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected launch changed before native dispatch");
if (!(this.platform.driver instanceof OpenShellDriver)) throw new EnvironmentError("environment_launch_unsupported", "This driver does not implement native launch preparation");
const claimed = this.platform.db.prepare("UPDATE environment_operations SET state = 'requested' WHERE operation_id = ? AND state = 'intent'").run(operationId);
if (!claimed.changes) throw new EnvironmentError("environment_native_unconfirmed", "The saved native launch intent needs reconciliation; no second launch was attempted");
await this.platform.driver.guest(selected, ["--prepare"]);
const command = ["rig", "environment", "connect", selected.bindingId, "--generation", generation].map(shellQuote).join(" ");
const sent = await tmux.sendShellCommand(binding.tmuxSession!, command, undefined, { stageIfLong: true, execInScript: true });
if (!sent.ok) throw new EnvironmentError("environment_viewer_unconfirmed", "The native execution may have started; its viewer dispatch is unconfirmed");
let observed: Record<string, any> | undefined;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try { observed = await this.platform.driver.guest(selected, ["--identity"]); break; }
catch { await new Promise(resolve => setTimeout(resolve, 250)); }
}
if (observed?.nativeId !== nativeId || observed.generation !== generation) throw new EnvironmentError("environment_native_unconfirmed", "The native process identity is not confirmed; nothing was restarted");
const receipt = { ...request, activityGrantId: activity.grantId, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: null };
this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId);
return { ok: true, resumeToken: nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher" };
} finally { rmSync(stage, { recursive: true }); }
}
}
@@ -222,6 +222,10 @@ export interface ForkSource {
* to the startup orchestrator after checkReady().
*/
export interface RuntimeAdapter {
/** Optional environment-driver v1 operations. Absence means unsupported; the host launch path is unchanged. */
readonly environment?: Pick<import("./environment-platform.js").EnvironmentPlatform, "protocolVersion" | "create" | "attach" | "project" | "validate">;
preflightEnvironmentStartup?(binding: NodeBinding, opts: { resumeToken?: string; forkSource?: unknown }): Promise<void>;
prepareEnvironmentStartup?(files: ResolvedStartupFile[], binding: NodeBinding): Promise<StartupDeliveryResult>;
/** Claude's managed capability/launch seam, shared with seat selection. */
readonly claudeManagedLaunch?: import("./claude-managed-launch.js").ClaudeManagedLaunch;
readonly runtime: string;
@@ -342,6 +342,16 @@ export class StartupOrchestrator {
if (guard && !guard.ownsLifecycle(input.nodeId)) {
return guard.lifecycle([input.nodeId], () => this.startNodeWithWarnings(input, warnings));
}
// A contained runtime reconciles saved execution before the ordinary startup prelude writes state or files.
if (input.adapter.environment) {
input = { ...input, binding: { ...input.binding, launchGeneration: input.binding.launchGeneration ?? this.sessionRegistry.currentOccupantTenure(input.nodeId)?.generationUuid } };
try {
if (!input.adapter.preflightEnvironmentStartup) throw new Error("Selected environment lacks startup reconciliation");
await input.adapter.preflightEnvironmentStartup(input.binding, { resumeToken: input.resumeToken, forkSource: input.forkSource });
} catch (error) {
return { ok: false, startupStatus: "attention_required", errors: [(error as Error).message] };
}
}
try {
input = { ...input, binding: new NativePermissionStore(this.db).apply(input.binding, input.adapter.runtime) };
if (input.adapter.runtime === "claude-code") {
@@ -398,6 +408,7 @@ export class StartupOrchestrator {
&& input.forkSource?.kind === "native_id" && input.forkSource.value?.trim()
? { parentToken: input.forkSource.value.trim(), isRestore: input.isRestore } : undefined;
const launchGeneration = this.sessionRegistry.currentOccupantTenure(input.nodeId)?.generationUuid;
if (input.adapter.environment) input = { ...input, binding: { ...input.binding, launchGeneration: input.binding.launchGeneration ?? launchGeneration } };
// 1. Mark pending
this.sessionRegistry.updateStartupStatus(input.sessionId, "pending");
@@ -451,6 +462,13 @@ export class StartupOrchestrator {
&& isSeatSkillsBootstrapLine(f));
const applicableFiles = sourceFiles.filter((f) => f.appliesOn.includes(context) && !input.replaySkip?.files.has(f.absolutePath)
&& !isSeatSkillsBootstrapLine(f));
if (input.adapter.environment) {
try {
if (!input.adapter.prepareEnvironmentStartup) throw new Error("Selected environment does not support required startup projection");
const prepared = await input.adapter.prepareEnvironmentStartup(applicableFiles, input.binding);
if (prepared.failed.length) return this.fail(input, "failed", prepared.failed.map(file => `Required environment startup input ${file.path}: ${file.error}`), undefined, false, undefined, false, "pre_launch_files", true);
} catch (error) { return this.fail(input, "failed", [`Environment startup preparation: ${(error as Error).message}`], undefined, false, undefined, false, "pre_launch_files", true); }
}
const preLaunchFiles: ResolvedStartupFile[] = [];
let postLaunchFiles: ResolvedStartupFile[] = [];
for (const f of applicableFiles) {
+11
View File
@@ -7,6 +7,7 @@ import { closeHttpServer, createDaemonShutdown, DAEMON_SHUTDOWN_RECEIPT, trackHt
import { readOpenRigEnv, OPENRIG_HOME } from "./openrig-compat.js";
import { makeOperatorDeliveryEngine } from "./domain/gateway/operator-delivery-engine.js";
import { resolveDaemonDbPath } from "./daemon-db-path.js";
import { createGuestActivityApp } from "./server.js";
import { createDaemon } from "./startup.js";
import { removeParentSessionEnv } from "./domain/parent-session-env.js";
import { resolveBindPlan } from "./domain/bind-plan.js";
@@ -253,6 +254,9 @@ async function isTrustedLocalOrTailnetBind(host: string): Promise<boolean> {
export async function startServer(port?: number) {
const p = port ?? parseInt(readOpenRigEnv("OPENRIG_PORT", "RIGGED_PORT") ?? "7433", 10);
const guestPort = process.env.OPENRIG_GUEST_ACTIVITY_PORT ? Number(process.env.OPENRIG_GUEST_ACTIVITY_PORT) : undefined;
if (guestPort !== undefined && (!Number.isInteger(guestPort) || guestPort < 1 || guestPort > 65535 || guestPort === p))
throw new Error("OPENRIG_GUEST_ACTIVITY_PORT must be a distinct valid listener port");
// D15 — anchor the default db under OPENRIG_HOME, never a bare CWD-relative
// filename (which could open the shared fleet db). Explicit OPENRIG_DB wins.
const dbPath = resolveDaemonDbPath(readOpenRigEnv("OPENRIG_DB", "RIGGED_DB"), OPENRIG_HOME);
@@ -317,6 +321,7 @@ export async function startServer(port?: number) {
const disarmStartupSignals = endProbeGroupsOnStartupSignal();
const { app, contextMonitor, deps, eventLoopMonitor, injectWebSocket, wakeRecovery } = await createDaemon({
dbPath,
guestActivityPort: guestPort,
bearerToken,
terminalBearerToken,
// S20 — the effective bind plan rides the health surface so adoption gates verify
@@ -335,6 +340,12 @@ export async function startServer(port?: number) {
// P7 — lifecycle heartbeat: advance last-seen every tick while running.
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const servers: ServerType[] = [];
if (guestPort !== undefined) {
const guest = createGuestActivityApp(deps);
const guestServer = serve({ fetch: guest.fetch, port: guestPort, hostname: process.env.OPENRIG_GUEST_ACTIVITY_HOST ?? "127.0.0.1" });
trackHttpServerResponses(guestServer);
servers.push(guestServer);
}
for (const host of bindHosts) {
const srv = serve({ fetch: app.fetch, port: p, hostname: host }, (info) => {
console.log(`OpenRig daemon listening on http://${host}:${info.port}`);
+86 -12
View File
@@ -1,13 +1,13 @@
import { runtimeSwitchActivityRole } from "./runtime-switch-custody.js";
import { physicalSessionName, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "../domain/session-registry.js";
import { SessionRegistry, physicalSessionName, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "../domain/session-registry.js";
import { existsSync } from "node:fs";
import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import type { AgentActivityStore } from "../domain/agent-activity-store.js";
import { hookEventState, isRunnerExit } from "../domain/agent-activity-store.js";
import type { SessionRegistry } from "../domain/session-registry.js";
import type { EventBus } from "../domain/event-bus.js";
import { verifyStartupProof } from "../domain/startup-proof.js";
import { ActivityGrantStore, grantGeneration, type ActivityGrant } from "../domain/activity-grants.js";
import type { ActivityEvidence, EvidenceRoute } from "../domain/activity-taxonomy.js";
import type { AgentActivity } from "../domain/types.js";
import * as parkedQuery from "../domain/parked-query.js";
@@ -110,6 +110,47 @@ function nextHookSeq(key: string): number {
export const activityRoutes = new Hono();
/** A seat activity grant's post, bound to the grant. A post that names another seat, or claims a generation the grant
* wasn't issued for, is refused; so is a grant whose occupant was replaced (stale) or hasn't registered yet (pending).
* A refusal records nothing, so a missing event stays unknown. Otherwise the seat and generation come from the grant. */
function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant, body: Record<string, unknown>):
{ body: Record<string, unknown> } | { refused: Response } {
const refuse = (status: 401 | 403 | 409 | 503, code: string, error: string) => ({ refused: c.json({ ok: false, code, error }, status) });
// Read the grant again: it may have been revoked or reissued while the body arrived. Nothing is awaited from here to the
// write, so this is the grant's state when the post takes effect.
if (new ActivityGrantStore(store.db).get(grant.grantId)?.revokedAt !== null) {
return refuse(401, "activity_grant_revoked", "This activity grant was revoked; nothing was recorded.");
}
const claimedNode = stringOrNull(body.nodeId);
const claimedSession = stringOrNull(body.sessionName);
if ((claimedNode && claimedNode !== grant.nodeId)
|| (claimedSession && store.resolveSession({ sessionName: claimedSession })?.nodeId !== grant.nodeId)) {
return refuse(403, "activity_grant_wrong_seat", "This activity grant speaks only for its own seat; nothing was recorded.");
}
const claimedGeneration = stringOrNull(body.generation);
if (claimedGeneration && claimedGeneration !== grant.generation) {
return refuse(409, "activity_grant_stale", "This post claims a launch generation its grant wasn't issued for; nothing was recorded.");
}
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
let standing: ReturnType<typeof grantGeneration>;
try {
standing = grantGeneration(grant, {
current: (nodeId) => registry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
registered: (nodeId, generation) => registry.isOccupantGenerationRegistered(nodeId, generation),
});
} catch {
return refuse(503, "generation_resolver_error", "Occupant generation is unavailable.");
}
if (standing === "stale") {
return refuse(409, "activity_grant_stale", "This activity grant's launch generation no longer occupies the seat; nothing was recorded.");
}
if (standing === "pending") {
return refuse(409, "activity_grant_pending", "This activity grant's launch hasn't registered its occupant yet; nothing was recorded.");
}
const sessionName = claimedSession ?? store.resolveSession({ nodeId: grant.nodeId })?.sessionName ?? null;
return { body: { ...body, nodeId: grant.nodeId, generation: grant.generation, ...(sessionName ? { sessionName } : {}) } };
}
/** The shared front of the Pi usage routes: configuration, the local hook token, a JSON object body, and the
* managed Pi seat it names. Returns the response to send on any failure. */
async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegistry["db"]) => boolean):
@@ -184,11 +225,13 @@ activityRoutes.post("/pi-usage-charges", async (c) => {
return c.json({ ok: true, ...result, rejected: charges.length - valid.length });
});
activityRoutes.post("/hooks", async (c) => {
/** The hook ingress. The main routes accept the install-wide token or a seat activity grant; the guest router accepts a
* grant only (`installTokenAccepted` false), so a guest-only listener never takes the token that speaks for every seat. */
async function postHook(c: Context, installTokenAccepted: boolean): Promise<Response> {
const store = c.get("agentActivityStore" as never) as AgentActivityStore | undefined;
const expectedToken = c.get("activityHookToken" as never) as string | undefined;
const expectedToken = installTokenAccepted ? c.get("activityHookToken" as never) as string | undefined : undefined;
if (!store || !expectedToken) {
if (!store || (installTokenAccepted && !expectedToken)) {
return c.json({
ok: false,
code: "activity_hook_unconfigured",
@@ -199,12 +242,22 @@ activityRoutes.post("/hooks", async (c) => {
const authHeader = c.req.header("authorization");
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length).trim() : null;
const headerToken = c.req.header("x-openrig-activity-token") ?? null;
if (bearerToken !== expectedToken && headerToken !== expectedToken) {
return c.json({
ok: false,
code: "activity_hook_unauthorized",
error: "Agent activity hook ingestion requires the configured local hook token.",
}, 401);
// The principal comes first. The install-wide token speaks for every seat, as before. A seat activity grant speaks
// only for its own seat and launch generation, which bindToGrant checks before anything the post claims is accepted.
let grant: ActivityGrant | null = null;
if (!expectedToken || (bearerToken !== expectedToken && headerToken !== expectedToken)) {
const grants = new ActivityGrantStore(store.db);
grant = grants.authenticate(bearerToken) ?? grants.authenticate(headerToken);
if (!grant) {
return c.json({
ok: false,
code: "activity_hook_unauthorized",
error: "Agent activity hook ingestion requires the configured local hook token.",
}, 401);
}
if (grant.revokedAt) {
return c.json({ ok: false, code: "activity_grant_revoked", error: "This activity grant was revoked; nothing was recorded." }, 401);
}
}
let body: Record<string, unknown>;
@@ -213,8 +266,16 @@ activityRoutes.post("/hooks", async (c) => {
} catch {
return c.json({ ok: false, code: "invalid_json", error: "Request body must be JSON." }, 400);
}
if (grant && (body === null || typeof body !== "object" || Array.isArray(body))) {
return c.json({ ok: false, code: "invalid_json", error: "Request body must be a JSON object." }, 400);
}
if (typeof body.sessionName === "string") body = { ...body, sessionName: physicalSessionName(store.db, body.sessionName) };
if (grant) {
const bound = bindToGrant(c, store, grant, body);
if ("refused" in bound) return bound.refused;
body = bound.body;
}
// Slice 16: a runtime switch's staged successor (before commit) and retired predecessor (after) don't speak for the
// seat. The staged successor's native identity is kept on the switch record, which the commit applies.
@@ -380,6 +441,11 @@ activityRoutes.post("/hooks", async (c) => {
listProcesses: c.get("listProcesses" as never) as NativeProcessLister | undefined });
}
}
// A grant's post is decided again after that wait, as it was after the body arrived.
if (grant) {
const still = bindToGrant(c, store, grant, body);
if ("refused" in still) return still.refused;
}
// #1524 (a), Codex only, synchronous with the write below (no await between them):
// - a known earlier occupant's hook (its generation is registered for this node and isn't the current one)
// neither writes nor publishes. An absent or unregistered generation, no current tenure, or an unreadable
@@ -569,7 +635,15 @@ activityRoutes.post("/hooks", async (c) => {
}
return c.json({ ok: true, activity: result.activity });
});
}
activityRoutes.post("/hooks", (c) => postHook(c, true));
/** The guest-facing activity ingress: the hook post alone, for a seat activity grant alone. A guest-only listener mounts
* this, never activityRoutes, whose other routes (events, parked, Pi usage) don't check a grant, and never the daemon's
* main listener, whose other routes trust any caller. */
export const guestActivityRoutes = new Hono();
guestActivityRoutes.post("/hooks", (c) => postHook(c, false));
// ── S19 AM-R18 — the push substrate: GET /api/activity/events (SSE) ──
// Desk-accepted shape (ruling row): CHANGE NOTIFICATIONS ONLY —
@@ -0,0 +1,51 @@
import { Hono, type Context } from "hono";
import { EnvironmentPlatform, EnvironmentError, type EnvironmentConfig } from "../domain/environment-platform.js";
import { EnvironmentArtifacts, type EnvironmentArtifactRequest } from "../domain/environment-artifacts.js";
import type { QueueRepository } from "../domain/queue-repository.js";
import { requireSenderIdentity, resolveRecordedIdentity } from "./require-sender-identity.js";
export const environmentRoutes = new Hono();
function platform(c: Context): EnvironmentPlatform { return c.get("environmentPlatform" as never) as EnvironmentPlatform; }
function failure(c: Context, error: unknown): Response {
if (error instanceof EnvironmentError) return c.json({ error: error.message, code: error.code }, error.status as 400);
return c.json({ code: "environment_unconfirmed", error: "The environment operation did not return a confirmed result; its intent is retained" }, 503);
}
environmentRoutes.post("/create", async c => {
const identity = requireSenderIdentity(c, { verb: "environment create" }); if (!identity.ok) return identity.response;
try {
const input = await c.req.json<EnvironmentConfig & { operationId: string }>();
const result = await platform(c).create(input);
return c.json({ ...result.binding, ...(result.code ? { code: result.code } : {}) }, result.status as 201);
} catch (error) { return failure(c, error); }
});
environmentRoutes.post("/attach", async c => {
const identity = requireSenderIdentity(c, { verb: "environment attach" }); if (!identity.ok) return identity.response;
try {
const result = await platform(c).attach(await c.req.json());
return c.json(result.binding, result.status as 201);
} catch (error) { return failure(c, error); }
});
environmentRoutes.get("/:bindingId", async c => {
try {
const service = platform(c);
const binding = c.req.query("live") === "true" ? await service.current(c.req.param("bindingId")) : service.get(c.req.param("bindingId"));
if (!binding) return c.json({ code: "environment_binding_missing", error: "No environment binding" }, 404);
return c.json({ ...binding, observation: c.req.query("live") === "true" ? "live-resource" : "stored-receipt",
lifecycle: { closeViewer: "unsupported", stopNative: "unsupported", stopCompute: "unsupported", deleteEnvironment: "unsupported", nativeRestore: "unsupported" } });
} catch (error) { return failure(c, error); }
});
environmentRoutes.post("/:bindingId/validate", async c => {
try {
const body = await c.req.json<{ generation: string }>();
await platform(c).validate(c.req.param("bindingId"), body.generation);
return c.json({ ok: true, generation: body.generation, effect: "destination-verified; native ingestion is not observed" });
} catch (error) { return failure(c, error); }
});
environmentRoutes.post("/:bindingId/export", async c => {
const identity = requireSenderIdentity(c, { verb: "environment export" }); if (!identity.ok) return identity.response;
try {
const service = new EnvironmentArtifacts(platform(c), c.get("queueRepo" as never) as QueueRepository, c.get("environmentArtifactRoot" as never) as string);
return c.json(await service.export(c.req.param("bindingId"), await c.req.json<EnvironmentArtifactRequest>(),
{ actor: identity.session, ...resolveRecordedIdentity(c, identity) }));
} catch (error) { return failure(c, error); }
});
+26 -1
View File
@@ -1,3 +1,6 @@
import { EnvironmentPlatform, type EnvironmentDriver } from "./domain/environment-platform.js";
import { OpenShellDriver } from "./adapters/openshell-driver.js";
import { environmentRoutes } from "./routes/environments.js";
import { healthDiagnosisRoutes } from "./routes/health-diagnosis.js";
import type { HealthDiagnosisService } from "./domain/health-diagnosis.js";
import type { HealthPolicyStore } from "./domain/health-policy.js";
@@ -62,7 +65,7 @@ import { transcriptRoutes } from "./routes/transcripts.js";
import { transportRoutes } from "./routes/transport.js";
import { compactionRoutes } from "./routes/compaction.js";
import type { ClaudeCompactionEnforcer } from "./domain/claude-compaction-enforcer.js";
import { activityRoutes } from "./routes/activity.js";
import { activityRoutes, guestActivityRoutes } from "./routes/activity.js";
import { askRoutes } from "./routes/ask.js";
import { wakeResolveRoutes } from "./routes/wake-resolve.js";
import type { AskService } from "./domain/ask-service.js";
@@ -146,6 +149,8 @@ import { createRouteTimingMiddleware } from "./domain/route-timing-recorder.js";
import { browserBoundary, type BrowserBoundaryOptions } from "./middleware/browser-boundary.js";
export interface AppDeps {
environmentDriver?: EnvironmentDriver;
environmentArtifactRoot?: string;
/** Boot-time DB context observations, not a live exclusion guarantee. */
databaseWarnings?: string[];
proofSourceWatch?: import("./domain/proof/source-watch.js").ProofSourceWatch;
@@ -446,6 +451,22 @@ function isUiAssetRequestPath(requestPath: string): boolean {
|| relativePath === "manifest.webmanifest";
}
/** Dedicated activity ingress. Never mount the host API or activity read routes here. */
export function createGuestActivityApp(deps: AppDeps): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("agentActivityStore" as never, deps.agentActivityStore);
c.set("sessionRegistry" as never, deps.sessionRegistry);
c.set("eventBus" as never, deps.eventBus);
c.set("seatActivityService" as never, deps.seatActivityService);
c.set("runtimeAdapters" as never, deps.runtimeAdapters ?? {});
c.set("tmuxAdapter" as never, deps.tmuxAdapter);
await next();
});
app.route("/api/activity", guestActivityRoutes);
return app;
}
export function createApp(deps: AppDeps): Hono {
// Hard runtime invariant: all domain services must share the same db handle.
if (deps.rigRepo.db !== deps.eventBus.db) {
@@ -500,6 +521,7 @@ export function createApp(deps: AppDeps): Hono {
throw new Error("createApp: podInstantiator must share the same db handle");
}
const environmentPlatform = new EnvironmentPlatform(deps.rigRepo.db, deps.environmentDriver ?? new OpenShellDriver());
const app = new Hono();
if (deps.requestPhaseObserver) app.use("*", deps.requestPhaseObserver.middleware());
// A malformed request body is a 400, not the default 500.
@@ -511,6 +533,8 @@ export function createApp(deps: AppDeps): Hono {
// Inject dependencies into context for all routes
app.use("*", async (c, next) => {
c.set("rigRepo" as never, deps.rigRepo);
c.set("environmentPlatform" as never, environmentPlatform);
c.set("environmentArtifactRoot" as never, deps.environmentArtifactRoot ?? nodePath.join(process.cwd(), "artifacts", "environments"));
c.set("sessionRegistry" as never, deps.sessionRegistry);
c.set("eventBus" as never, deps.eventBus);
c.set("nodeLauncher" as never, deps.nodeLauncher);
@@ -792,6 +816,7 @@ export function createApp(deps: AppDeps): Hono {
// Slice-04 OPR.0.5.0.4: registered so the endpoints exist and 503 honestly; the providerService
// is not set in context until the collection/service seam (C) wires it (until then -> 503).
app.route("/api/provider", providerRoutes());
app.route("/api/environments", environmentRoutes);
app.route("/api/seat", seatRoutes);
app.route("/api/rigs/:rigId/chat", chatRoutes());
app.route("/api/stream", streamRoutes());
+24 -2
View File
@@ -1,3 +1,6 @@
import { EnvironmentStartup } from "./domain/environment-startup.js";
import { EnvironmentPlatform } from "./domain/environment-platform.js";
import { OpenShellDriver } from "./adapters/openshell-driver.js";
import { isBoundTerminalSeat } from "./domain/terminal-seat.js";
import { seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js";
import { reportRestoreDeliveryFailure } from "./domain/compaction-restore-notice.js";
@@ -206,6 +209,8 @@ import {
} from "./openrig-compat.js";
interface DaemonOptions {
/** Explicit confined listener; absent means guest activity is unavailable. */
guestActivityPort?: number;
dbPath?: string;
/** S20 — the effective bind plan (mode/hosts/tailscaleDetected/ignoredRoutingHost),
* computed once in index.ts and exposed on /healthz so adoption gates verify
@@ -1140,11 +1145,26 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
},
);
const environmentDriver = new OpenShellDriver();
const environmentPlatform = new EnvironmentPlatform(db, environmentDriver);
const checkGuestActivityEndpoint = (origin: string) => {
const url = new URL(origin);
if (!opts?.guestActivityPort || Number(url.port || (url.protocol === "https:" ? 443 : 80)) !== opts.guestActivityPort)
throw new Error("The guest activity origin must name the configured guest-only listener; the host API is not a guest endpoint");
};
const environmentStartup = new EnvironmentStartup(environmentPlatform, nodePath.join(OPENRIG_HOME, "state", "environment-staging"), async input => {
// The one shared P6 implementation owns token issuance and ingress validation.
const { ActivityGrantStore, guestActivityEnv } = await import("./domain/activity-grants.js");
checkGuestActivityEndpoint(input.guestBaseUrl);
const grant = new ActivityGrantStore(db).issue({ nodeId: input.nodeId, generation: input.generation, issuedBy: input.seat });
return { grantId: grant.grantId, env: guestActivityEnv(grant, input.seat, input.guestBaseUrl) };
}, checkGuestActivityEndpoint);
const podInstantiator = new PodRigInstantiator({
db, rigRepo, podRepo,
sessionRegistry, eventBus, nodeLauncher, startupOrchestrator,
fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), exists: (p: string) => fs.existsSync(p) },
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment }),
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup }),
tmuxAdapter,
agentImageLibrary,
continuityPolicyMaterializer,
@@ -1208,7 +1228,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
"omp": ompAdapter,
"stub": stubAdapter,
"terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter(),
}, { tmux: tmuxAdapter, seatLaunchEnvironment });
}, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup });
let kernelBootTracker: import("./domain/kernel-boot-tracker.js").KernelBootTracker | undefined;
try {
const { bootKernelIfNeeded } = await import("./domain/kernel-boot.js");
@@ -1412,6 +1432,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const deps: AppDeps = {
environmentDriver,
environmentArtifactRoot: nodePath.join(runtimeSettings.workspaceRoot, "artifacts", "environments"),
databaseWarnings,
rigRepo,
sessionRegistry,
@@ -0,0 +1,257 @@
// A seat activity grant on the existing hook ingress, through the real route, store, occupant ledger and oracle. The
// grant is resolved first; only then is the seat and generation a post claims accepted. A refused post records nothing,
// so the seat's activity stays unknown. The install-wide token keeps today's behaviour.
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
import { activityRoutes, restoredRunnerHookEvidence } from "../src/routes/activity.js";
import * as activityModule from "../src/routes/activity.js";
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
import { createFullTestDb } from "./helpers/test-app.js";
const GUEST = "dev-guest@guest-rig", OTHER = "dev-other@guest-rig", INSTALL_TOKEN = "install-token";
const cleanups: Array<() => void> = [];
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
/** Two runner seats on one rig: the guest, which holds the grant, and another seat. `guest` mounts the guest router, as a
* guest-only listener does, instead of the main activity routes; it has no install-wide token. */
function fixture(options: { guest?: boolean } = {}) {
const clock = { now: Date.parse("2026-10-30T03:00:00.000Z") };
const db = createFullTestDb();
cleanups.push(() => db.close());
const rigs = new RigRepository(db);
const rig = rigs.createRig("guest-rig");
const guest = rigs.addNode(rig.id, "dev.guest", { runtime: "synth" });
const other = rigs.addNode(rig.id, "dev.other", { runtime: "synth" });
const sessions = new SessionRegistry(db);
const occupy = (nodeId: string, name: string, kind?: "handover", reserved?: string) => {
const session = sessions.registerSession(nodeId, name, kind, reserved);
sessions.updateStatus(session.id, "running");
sessions.recordCommunityHarnessSelection(session.id, "synth", true);
};
occupy(guest.id, GUEST);
occupy(other.id, OTHER);
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now: () => new Date(clock.now),
resolveOccupantGeneration: id => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
isRegisteredOccupantGeneration: (id, generation) => sessions.isOccupantGenerationRegistered(id, generation) });
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null }, defaultWindowSeconds: 3,
now: () => new Date(clock.now), restoreHookEvidence: (seat, seatRuntime) => restoredRunnerHookEvidence(store, seat, seatRuntime) });
const app = new Hono();
app.use("*", async (c, next) => {
c.set("agentActivityStore" as never, store as never);
if (!options.guest) c.set("activityHookToken" as never, INSTALL_TOKEN as never);
c.set("sessionRegistry" as never, sessions as never);
c.set("seatActivityService" as never, oracle as never);
await next();
});
const guestRoutes = (activityModule as unknown as Record<string, Hono | undefined>).guestActivityRoutes;
if (options.guest && !guestRoutes) throw new Error("routes/activity.ts exports no guestActivityRoutes");
app.route("/api/activity", options.guest ? guestRoutes! : activityRoutes);
const grants = new ActivityGrantStore(db, () => new Date(clock.now));
const generation = (nodeId: string) => sessions.currentOccupantTenure(nodeId)!.generationUuid;
const issue = (gen = generation(guest.id)) => grants.issue({ nodeId: guest.id, generation: gen, issuedBy: "driver@test" });
/** A runner's post; `body` is sent as given, so a test can send malformed JSON. */
async function post(token: string | null, body: unknown, header: "bearer" | "x-openrig-activity-token" = "bearer") {
clock.now += 1000;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token !== null) headers[header === "bearer" ? "authorization" : header] = header === "bearer" ? `Bearer ${token}` : token;
const response = await app.request("/api/activity/hooks", { method: "POST", headers,
body: typeof body === "string" ? body : JSON.stringify(body) });
return { status: response.status, body: await response.json() as Record<string, unknown> };
}
/** A post whose body arrives only when released, so a test can act after the route authenticated it and while it reads
* the body. The stream has no buffer, so it's read only when the route asks for the body. */
function heldPost(token: string, body: unknown) {
let started!: () => void, release!: () => void;
const reading = new Promise<void>((resolve) => { started = resolve; });
const gate = new Promise<void>((resolve) => { release = resolve; });
const bytes = new TextEncoder().encode(JSON.stringify(body));
const stream = new ReadableStream<Uint8Array>({
async pull(controller) { started(); await gate; controller.enqueue(bytes); controller.close(); },
}, { highWaterMark: 0 });
clock.now += 1000;
const response = app.request("/api/activity/hooks", { method: "POST", body: stream, duplex: "half",
headers: { authorization: `Bearer ${token}`, "Content-Type": "application/json" } } as RequestInit)
.then(async (res) => ({ status: res.status, body: await res.json() as Record<string, unknown> }));
return { reading, release, response };
}
/** Any request to the mounted app, for the paths a guest must not reach. */
async function raw(method: string, path: string, token: string) {
const response = await app.request(path, { method,
headers: { authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(method === "GET" ? {} : { body: "{}" }) });
return response.status;
}
const event = (hookEvent: string, subtype: string, identity: Record<string, unknown> = {}) =>
({ runtime: "synth", hookEvent, subtype, occurredAt: new Date(clock.now + 1000).toISOString(), ...identity });
const recorded = () => (db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.activity'").get() as { n: number }).n;
const activity = (nodeId: string) => oracle.getSeatState(nodeId)?.activity ?? "unknown";
const replace = (reserved?: string) => occupy(guest.id, GUEST, "handover", reserved);
return { guest, other, sessions, grants, generation, issue, post, heldPost, raw, event, recorded, activity, replace };
}
describe("a seat activity grant on the hook ingress", () => {
it("speaks for its own seat: the runner's posts decide the seat's activity, with the seat and generation from the grant", async () => {
const f = fixture();
const grant = f.issue();
expect(await f.post(grant.token, f.event("UserPromptSubmit", "accepted", { sessionName: GUEST }))).toMatchObject({ status: 200 });
expect(f.activity(f.guest.id)).toBe("working");
// A guest may leave out the seat and generation; the grant supplies them.
const idle = await f.post(grant.token, f.event("Stop", "end_turn"), "x-openrig-activity-token");
expect(idle).toMatchObject({ status: 200, body: { activity: { generation: grant.generation } } });
expect(f.activity(f.guest.id)).toBe("idle-at-prompt");
expect(f.recorded()).toBe(2);
});
it("keeps the install-wide token's behaviour for every seat", async () => {
const f = fixture();
f.issue();
for (const [nodeId, sessionName] of [[f.guest.id, GUEST], [f.other.id, OTHER]] as const) {
expect(await f.post(INSTALL_TOKEN, f.event("UserPromptSubmit", "accepted", { sessionName, nodeId, generation: f.generation(nodeId) })))
.toMatchObject({ status: 200 });
expect(f.activity(nodeId)).toBe("working");
}
});
const wrongSeat: Array<[string, (f: ReturnType<typeof fixture>) => Record<string, unknown>]> = [
["another seat's name", () => ({ sessionName: OTHER })],
["another seat's node", (f) => ({ nodeId: f.other.id })],
["its own node with another seat's name", (f) => ({ nodeId: f.guest.id, sessionName: OTHER })],
["a seat that doesn't exist", () => ({ sessionName: "dev-nobody@guest-rig" })],
];
it.each(wrongSeat)("refuses a post naming %s, and records nothing", async (_case, identity) => {
const f = fixture();
const grant = f.issue();
expect(await f.post(grant.token, f.event("Stop", "end_turn", identity(f))))
.toMatchObject({ status: 403, body: { code: "activity_grant_wrong_seat" } });
expect(f.recorded()).toBe(0);
expect(f.activity(f.other.id)).toBe("unknown");
});
it("refuses a post claiming a generation its grant wasn't issued for", async () => {
const f = fixture();
const grant = f.issue();
expect(await f.post(grant.token, f.event("Stop", "end_turn", { generation: "another-generation" })))
.toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
expect(f.recorded()).toBe(0);
});
it("refuses a replaced occupant's grant as stale", async () => {
const f = fixture();
const grant = f.issue();
f.replace();
expect(await f.post(grant.token, f.event("Stop", "end_turn"))).toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
expect(f.recorded()).toBe(0);
});
it("refuses a grant for a launch that hasn't registered its occupant yet, then accepts it once it has", async () => {
const f = fixture();
const reserved = f.sessions.reserveOccupantGeneration()!;
const grant = f.issue(reserved);
expect(await f.post(grant.token, f.event("UserPromptSubmit", "accepted"))).toMatchObject({ status: 409, body: { code: "activity_grant_pending" } });
expect(f.recorded()).toBe(0);
f.replace(reserved);
expect(await f.post(grant.token, f.event("UserPromptSubmit", "accepted"))).toMatchObject({ status: 200 });
expect(f.activity(f.guest.id)).toBe("working");
});
it("refuses a revoked grant, including one replaced by a reissue", async () => {
const f = fixture();
const first = f.issue();
const second = f.issue();
expect(await f.post(first.token, f.event("Stop", "end_turn"))).toMatchObject({ status: 401, body: { code: "activity_grant_revoked" } });
expect(await f.post(second.token, f.event("UserPromptSubmit", "accepted"))).toMatchObject({ status: 200 });
f.grants.revoke({ nodeId: f.guest.id }, "stopped");
expect(await f.post(second.token, f.event("Stop", "end_turn"))).toMatchObject({ status: 401, body: { code: "activity_grant_revoked" } });
expect(f.recorded()).toBe(1);
expect(f.activity(f.guest.id)).toBe("working");
});
it.each(["revoked", "reissued"] as const)("refuses a post whose grant was %s while its body was arriving, and records nothing", async (change) => {
const f = fixture();
const grant = f.issue();
const held = f.heldPost(grant.token, f.event("Stop", "end_turn"));
await held.reading;
if (change === "revoked") f.grants.revoke({ nodeId: f.guest.id }, "stopped");
else f.issue();
held.release();
expect(await held.response).toMatchObject({ status: 401, body: { code: "activity_grant_revoked" } });
expect(f.recorded()).toBe(0);
expect(f.activity(f.guest.id)).toBe("unknown");
});
it.each([
["an unknown grant", `oag_${"0".repeat(64)}`],
["another token", "not-the-token"],
["no token", null],
])("refuses %s as unauthorized", async (_case, token) => {
const f = fixture();
f.issue();
expect(await f.post(token, f.event("Stop", "end_turn", { sessionName: GUEST })))
.toMatchObject({ status: 401, body: { code: "activity_hook_unauthorized" } });
expect(f.recorded()).toBe(0);
});
it.each([["broken JSON", "{"], ["null", "null"], ["a list", "[]"]])("refuses %s from a grant holder", async (_case, raw) => {
const f = fixture();
const grant = f.issue();
expect(await f.post(grant.token, raw)).toMatchObject({ status: 400, body: { code: "invalid_json" } });
expect(f.recorded()).toBe(0);
});
it("leaves a seat unknown, never idle, when every post was refused", async () => {
const f = fixture();
const grant = f.issue();
await f.post(grant.token, f.event("Stop", "end_turn", { sessionName: OTHER }));
await f.post(grant.token, f.event("Stop", "end_turn", { generation: "another-generation" }));
f.grants.revoke({ nodeId: f.guest.id }, "stopped");
await f.post(grant.token, f.event("Stop", "end_turn"));
expect(f.recorded()).toBe(0);
expect(f.activity(f.guest.id)).toBe("unknown");
expect(f.activity(f.other.id)).toBe("unknown");
});
});
describe("the guest router: only the hook post, and only for a grant", () => {
it("takes a grant's post for its own seat", async () => {
const f = fixture({ guest: true });
const grant = f.issue();
expect(await f.post(grant.token, f.event("UserPromptSubmit", "accepted"))).toMatchObject({ status: 200 });
expect(f.activity(f.guest.id)).toBe("working");
});
it("refuses the install-wide token", async () => {
const f = fixture({ guest: true });
f.issue();
expect(await f.post(INSTALL_TOKEN, f.event("Stop", "end_turn", { sessionName: GUEST, nodeId: f.guest.id, generation: f.generation(f.guest.id) })))
.toMatchObject({ status: 401, body: { code: "activity_hook_unauthorized" } });
expect(f.recorded()).toBe(0);
});
it.each([
["GET", "/api/activity/hooks"], ["GET", "/api/activity/events"], ["GET", "/api/activity/parked?rig=guest-rig"],
["POST", "/api/activity/pi-usage"], ["POST", "/api/activity/pi-usage-charges"], ["PUT", "/api/activity/hooks"],
["POST", "/api/config"], ["POST", "/api/queue"],
])("serves nothing at %s %s", async (method, path) => {
const f = fixture({ guest: true });
const grant = f.issue();
expect(await f.raw(method, path, grant.token)).toBe(404);
expect(f.recorded()).toBe(0);
});
it("still refuses another seat, another generation and a revoked grant, and records nothing", async () => {
const f = fixture({ guest: true });
const grant = f.issue();
expect(await f.post(grant.token, f.event("Stop", "end_turn", { sessionName: OTHER })))
.toMatchObject({ status: 403, body: { code: "activity_grant_wrong_seat" } });
expect(await f.post(grant.token, f.event("Stop", "end_turn", { generation: "another-generation" })))
.toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
f.grants.revoke({ nodeId: f.guest.id }, "stopped");
expect(await f.post(grant.token, f.event("Stop", "end_turn"))).toMatchObject({ status: 401, body: { code: "activity_grant_revoked" } });
expect(f.recorded()).toBe(0);
expect(f.activity(f.guest.id)).toBe("unknown");
});
});
@@ -0,0 +1,92 @@
// The seat activity grant's store and the only activity values a guest receives.
import { afterEach, describe, expect, it } from "vitest";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { ActivityGrantStore, grantGeneration, guestActivityEnv } from "../src/domain/activity-grants.js";
import { createFullTestDb } from "./helpers/test-app.js";
const cleanups: Array<() => void> = [];
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
function fixture() {
const db = createFullTestDb();
cleanups.push(() => db.close());
const rigs = new RigRepository(db);
const rig = rigs.createRig("guest-rig");
const node = rigs.addNode(rig.id, "dev.guest", { runtime: "synth" });
const sessions = new SessionRegistry(db);
sessions.registerSession(node.id, "dev-guest@guest-rig");
const generation = sessions.currentOccupantTenure(node.id)!.generationUuid;
const ledger = { current: (id: string) => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
registered: (id: string, g: string) => sessions.isOccupantGenerationRegistered(id, g) };
return { db, node, sessions, generation, ledger, grants: new ActivityGrantStore(db) };
}
describe("issuing a seat activity grant", () => {
it("returns the token once and stores only its sha256", () => {
const f = fixture();
const issued = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
expect(issued.token).toMatch(/^oag_[0-9a-f]{64}$/);
expect(issued).toMatchObject({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test", revokedAt: null });
const rows = f.db.prepare("SELECT * FROM activity_grants").all();
expect(rows).toHaveLength(1);
expect(JSON.stringify(rows)).not.toContain(issued.token);
expect(f.grants.get(issued.grantId)).not.toHaveProperty("token");
expect(f.grants.authenticate(issued.token)?.grantId).toBe(issued.grantId);
});
it("keeps one active grant per seat: issuing again revokes the earlier one", () => {
const f = fixture();
const first = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
const second = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
expect(f.grants.get(first.grantId)).toMatchObject({ revokeReason: "reissued" });
expect(f.grants.get(first.grantId)?.revokedAt).not.toBeNull();
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(second.grantId);
});
it("revokes by grant or by seat, once", () => {
const f = fixture();
const issued = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
expect(f.grants.revoke({ nodeId: f.node.id }, "stopped")).toBe(1);
expect(f.grants.revoke({ grantId: issued.grantId }, "again")).toBe(0);
expect(f.grants.get(issued.grantId)).toMatchObject({ revokeReason: "stopped" });
expect(f.grants.activeForNode(f.node.id)).toBeNull();
// A revoked grant still authenticates, so the ingress can say it was revoked.
expect(f.grants.authenticate(issued.token)?.revokedAt).not.toBeNull();
});
it("refuses an unknown seat and blank input, and knows no other token", () => {
const f = fixture();
expect(() => f.grants.issue({ nodeId: "no-such-node", generation: f.generation, issuedBy: "driver@test" })).toThrow(/No seat/);
expect(() => f.grants.issue({ nodeId: f.node.id, generation: " ", issuedBy: "driver@test" })).toThrow(/needs a node/);
expect(f.db.prepare("SELECT COUNT(*) AS n FROM activity_grants").get()).toEqual({ n: 0 });
for (const token of [null, "", "install-token", `oag_${"0".repeat(64)}`]) expect(f.grants.authenticate(token)).toBeNull();
});
it("places a grant against the occupant ledger: current, stale after replacement, pending before registration", () => {
const f = fixture();
const current = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
expect(grantGeneration(current, f.ledger)).toBe("current");
const reserved = f.sessions.reserveOccupantGeneration()!;
const next = f.grants.issue({ nodeId: f.node.id, generation: reserved, issuedBy: "driver@test" });
expect(grantGeneration(next, f.ledger)).toBe("pending");
f.sessions.registerSession(f.node.id, "dev-guest@guest-rig", "handover", reserved);
expect(grantGeneration(next, f.ledger)).toBe("current");
expect(grantGeneration(current, f.ledger)).toBe("stale");
});
});
describe("the activity values a guest receives", () => {
const grant = { token: "oag_guest", nodeId: "node-1", generation: "gen-1" };
it("are exactly the grant, the seat and a guest-reachable origin", () => {
expect(guestActivityEnv(grant, "dev-guest@guest-rig", "http://host.containers.internal:7433/")).toEqual({
OPENRIG_URL: "http://host.containers.internal:7433", OPENRIG_ACTIVITY_HOOK_TOKEN: "oag_guest", OPENRIG_NODE_ID: "node-1",
OPENRIG_SESSION_NAME: "dev-guest@guest-rig", OPENRIG_OCCUPANT_GENERATION: "gen-1" });
});
it.each(["not a url", "ftp://host:7433", "http://host:7433/api/activity", "http://user:secret@host:7433", "http://host:7433/?a=1", "http://host:7433/#x"])(
"refuse %s as the guest's address", (address) => {
expect(() => guestActivityEnv(grant, "dev-guest@guest-rig", address)).toThrow(/guest's activity address/);
});
});
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
const cleanup: Array<() => void> = [];
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
async function fixture(attached = false) {
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-lifecycle-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const db = createFullTestDb(); cleanup.push(() => db.close());
const resource = { id: "compute-one", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
let present = attached, running = true, unavailable = false, loseStop = false, stopCalls = 0, deleteCalls = 0, creates = 0;
let dependents = { workload: false, supervisor: false, volumes: false };
const files = new Map<string, Buffer>();
const native = { nativeId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", nativeType: "community:openshell-claude", generation: "generation-one" };
const history = Buffer.from(JSON.stringify({ sessionId: native.nativeId, type: "user", message: { content: "retained context" } }) + "\n");
files.set(`/sandbox/openrig/native/projects/-sandbox-openrig-work/${native.nativeId}.jsonl`, history);
const driver = { version: 1 as const,
list: async () => { if (unavailable) throw new Error("provider unavailable"); return { complete: true, resources: present ? [{ ...resource }] : [] }; },
create: async (input: { operationId: string }) => { creates++; present = true; resource.operationId = input.operationId; return { ...resource }; },
inspect: async () => { if (unavailable || !present) throw new Error("lookup unavailable"); return { ...resource }; },
upload: async (_b: unknown, source: string, destination: string) => { files.set(destination, readFileSync(source)); },
read: async (_b: unknown, destination: string) => { const data = files.get(destination); if (!data) throw new Error("missing"); return data; },
download: async (_b: unknown, source: string, destination: string) => { writeFileSync(destination, files.get(source)!); },
nativeIdentity: async () => ({ ...native, running }),
stopNative: async () => { stopCalls++; running = false; if (loseStop) throw new Error("reply lost"); },
stopCompute: async () => { stopCalls++; running = false; resource.phase = "Stopped"; if (loseStop) throw new Error("reply lost"); },
deleteEnvironment: async () => { deleteCalls++; present = false; },
cleanupState: async () => ({ ...dependents }),
checkpointFiles: async () => [{ path: `native/projects/-sandbox-openrig-work/${native.nativeId}.jsonl`, bytes: history.length, sha256: (await import("../src/domain/environment-platform.js")).sha256(history) }],
};
const f = createTestApp(db, { appDeps: { environmentDriver: driver, environmentArtifactRoot: join(root, "artifacts"), environmentStateRoot: join(root, "retained") } as never });
const rig = f.rigRepo.createRig("lifecycle"), node = f.rigRepo.addNode(rig.id, "worker", { runtime: "openshell-claude", cwd: root });
const request = async (path: string, body?: unknown) => { const res = await f.app.request(`/api/environments${path}`, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-openrig-session": "coordinator@lifecycle" }, ...(body ? { body: JSON.stringify(body) } : {}) }); const text = await res.text(); let parsed: Record<string, any>; try { parsed = JSON.parse(text); } catch { parsed = { error: text }; } return { status: res.status, body: parsed }; };
const policy = join(root, "policy.yaml"); writeFileSync(policy, "version: 1\n");
const input = { version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id, seat: "worker@lifecycle", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7433", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } };
const created = await request(attached ? "/attach" : "/create", { ...input, ...(attached ? { resourceId: resource.id } : {}) }); expect(created.status, JSON.stringify(created.body)).toBeLessThan(300);
db.prepare("UPDATE environment_bindings SET generation='generation-one' WHERE binding_id='binding-one'").run();
const receipt = { version: 1, bindingId: "binding-one", nodeId: node.id, resourceId: resource.id, ...native, activityGrantId: "grant-one" };
db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state,receipt_json) VALUES ('launch-generation-one','binding-one','launch',?,'confirmed',?)").run(JSON.stringify(receipt), JSON.stringify(receipt));
const effect = (name: string, operationId = "effect-one") => request("/binding-one/effect", { operationId, generation: "generation-one", effect: name });
return { root, db, request, effect, resource, native, files, history, counts: () => ({ stopCalls, deleteCalls, creates }), loseStop: () => { loseStop = true; }, unavailable: () => { unavailable = true; }, dirtyCleanup: () => { dependents.volumes = true; } };
}
describe("owned and attached environment effects", () => {
it("stops only the selected native and reconciles a lost acknowledgement without a second signal", async () => {
const f = await fixture(); f.loseStop();
const first = await f.effect("stop-native"); expect(first.body.state).toBe("confirmed");
expect(first.body.effect).toBe("stop-native");
const replay = await f.effect("stop-native"); expect(replay.body).toEqual(first.body); expect(f.counts().stopCalls).toBe(1);
expect(f.resource.phase).toBe("Ready"); expect(f.counts().deleteCalls).toBe(0);
});
it("observes stopped compute independently from native and viewer state", async () => {
const f = await fixture(); const result = await f.effect("stop-compute");
expect(result.body).toMatchObject({ state: "confirmed", effect: "stop-compute", resourceId: "compute-one" });
expect(f.resource.phase).toBe("Stopped"); expect(f.counts().deleteCalls).toBe(0);
});
it.each(["stop-native", "stop-compute", "delete-owned"])("preserves attached resources for %s", async effect => {
const f = await fixture(true), result = await f.effect(effect);
expect(result.status).toBe(409); expect(result.body.code).toBe("environment_attached_preserved");
expect(f.counts()).toMatchObject({ stopCalls: 0, deleteCalls: 0, creates: 0 });
});
it("does not call provider absence complete until workload, supervisor and volumes are all observed absent", async () => {
const f = await fixture(); f.dirtyCleanup();
const result = await f.effect("delete-owned"); expect(result.body.state).toBe("unconfirmed");
expect(result.body.remaining.volumes).toBe(true); expect(f.counts().deleteCalls).toBe(1);
await f.effect("delete-owned"); expect(f.counts().deleteCalls).toBe(1);
});
it("keeps the original operation pending when a lookup fails and never infers absence", async () => {
const f = await fixture(); f.unavailable();
const result = await f.effect("delete-owned"); expect(result.body.state).toBe("unconfirmed");
expect(f.counts().deleteCalls).toBe(0);
});
it("refuses the wrong generation before any provider mutation", async () => {
const f = await fixture(), result = await f.request("/binding-one/effect", { operationId: "stale", generation: "older", effect: "stop-compute" });
expect(result.status).toBe(409); expect(f.counts().stopCalls).toBe(0);
});
});
describe("exact contained checkpoint and restore", () => {
it("retains verified private history bytes and nonsecret exact native/compute references", async () => {
const f = await fixture(), result = await f.request("/binding-one/checkpoint", { operationId: "checkpoint-one", generation: "generation-one" });
expect(result.status).toBe(200); expect(result.body).toMatchObject({ version: 1, bindingId: "binding-one", nativeId: f.native.nativeId, nativeType: "community:openshell-claude", predecessorResourceId: "compute-one" });
expect(result.body.files).toHaveLength(1); expect(readFileSync(result.body.files[0].source)).toEqual(f.history);
expect(JSON.stringify(result.body)).not.toContain("retained context");
});
it("rejects missing checkpoint state before creating successor compute", async () => {
const f = await fixture(), result = await f.request("/binding-one/recreate", { operationId: "recreate-one", generation: "generation-two", checkpointId: "missing", resumeType: "community:openshell-claude", resumeToken: f.native.nativeId, placement: { ...f.resource, name: "worker-two" } });
expect(result.status).toBe(409); expect(f.counts().creates).toBe(1);
});
it("refuses another native type without starting a fresh conversation", async () => {
const f = await fixture(), result = await f.request("/binding-one/recreate", { operationId: "recreate-one", generation: "generation-two", checkpointId: "missing", resumeType: "codex_id", resumeToken: "foreign" });
expect(result.status).toBe(409); expect(f.counts().creates).toBe(1);
});
});
@@ -0,0 +1,239 @@
import { QueueRepository } from "../src/domain/queue-repository.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
import { EventBus } from "../src/domain/event-bus.js";
import { afterEach, describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { EnvironmentError, EnvironmentPlatform } from "../src/domain/environment-platform.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
const cleanup: Array<() => void> = [];
afterEach(() => { for (const f of cleanup.splice(0).reverse()) f(); });
const hash = (bytes: string) => createHash("sha256").update(bytes).digest("hex");
// The real daemon routes, SQLite and host filesystem; only the provider transport is fake.
// These checks never launch OpenShell or a native agent.
function fixture() {
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-platform-")));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const db = createFullTestDb(); cleanup.push(() => db.close());
const resources: Array<Record<string, unknown>> = [], files = new Map<string, string>();
let creates = 0, downloads = 0, loseCreate = false;
const driver = {
version: 1,
async list() { return { complete: true, resources: [...resources] }; },
async create(input: { operationId: string; placement: { name: string; workspace: string } }) {
creates++;
expect(db.prepare("SELECT count(*) AS n FROM environment_operations WHERE effect_id = ?").get(input.operationId)).toMatchObject({ n: 1 });
const resource = { id: "immutable-resource-1", name: input.placement.name, workspace: input.placement.workspace,
phase: "Ready", fingerprint: "host-key-1", operationId: input.operationId };
resources.push(resource);
if (loseCreate) throw new Error("response lost after create");
return resource;
},
async inspect() { if (!resources.length) throw new Error("provider unavailable"); return { ...resources[0] }; },
async upload(_binding: unknown, local: string, destination: string) { files.set(destination, readFileSync(local, "utf8")); },
async read(_binding: unknown, destination: string) { if (!files.has(destination)) throw new EnvironmentError("environment_file_missing", "Guest file is missing"); return Buffer.from(files.get(destination)!); },
async download(_binding: unknown, source: string, destination: string) { downloads++; if (!files.has(source)) throw new Error("guest unavailable"); writeFileSync(destination, files.get(source)!); },
};
const queue = new QueueRepository(db, new EventBus(db)); queue.attachOutbox(new OutboxHandler(db));
const build = () => createTestApp(db, { appDeps: { queueRepo: queue, environmentDriver: driver, environmentArtifactRoot: join(root, "exports") } as never });
let f = build();
const rig = f.rigRepo.createRig("environment-fixture");
const node = f.rigRepo.addNode(rig.id, "worker", { runtime: "openshell-claude", cwd: root });
const request = async (path: string, body?: unknown) => {
const res = await f.app.request(`/api/environments${path}`, { method: body === undefined ? "GET" : "POST",
headers: { "content-type": "application/json", "x-openrig-session": "coordinator@environment-fixture" },
...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { status: res.status, body: await res.json() as Record<string, any> };
};
const input = { version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id,
seat: "worker@environment-fixture", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://host.test:7782",
placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider-ref", policy: join(root, "policy.yaml") } };
writeFileSync(input.placement.policy, "version: 1\n"); mkdirSync(join(root, "exports"));
const project = async (body: any) => {
try { return { status: 200, body: await new EnvironmentPlatform(db, driver as never).project("binding-one", body) }; }
catch (error) { return { status: error instanceof EnvironmentError ? error.status : 503, body: {} }; }
};
const create = () => request("/create", input);
const task = async () => {
const response = await f.app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "x-openrig-session": "coordinator@environment-fixture" },
body: JSON.stringify({ qitemId: "task-one", destinationSession: "worker@environment-fixture", body: "Produce the selected artifact" }) });
expect(response.status, JSON.stringify(await response.clone().json())).toBeLessThan(300);
};
return { root, db, resources, files, driver, input, request, create, task, project, health: () => f.app.request("/healthz"),
counts: () => ({ creates, downloads }), loseCreate: () => { loseCreate = true; }, restart: () => { f = build(); } };
}
describe("maintained environment binding and projection", () => {
it("journals create intent before the provider effect and persists the immutable owned receipt", async () => {
const f = fixture(), result = await f.create();
expect(result.status).toBe(201);
expect(result.body).toMatchObject({ version: 1, bindingId: "binding-one", ownership: "owned", nodeId: f.input.nodeId,
resource: { id: "immutable-resource-1", workspace: "workspace" } });
expect(f.counts().creates).toBe(1);
expect((await f.request("/binding-one")).body).toMatchObject({ bindingId: "binding-one", ownership: "owned" });
});
it("reconciles a lost create response after daemon reconstruction without another create", async () => {
const f = fixture(); f.loseCreate();
expect((await f.create()).status).toBe(202);
f.restart();
const result = await f.create();
expect(result.status).toBe(200);
expect(result.body.resource.id).toBe("immutable-resource-1");
expect(f.counts().creates).toBe(1);
});
it("keeps an unresolved create intent when the provider cannot establish absence", async () => {
const f = fixture(); f.loseCreate();
expect((await f.create()).status).toBe(202);
f.driver.list = async () => ({ complete: false, resources: [] });
expect((await f.create()).status).toBe(202);
expect(f.counts().creates).toBe(1);
});
it("refuses duplicate matches instead of choosing a resource or repeating create", async () => {
const f = fixture(); f.loseCreate();
expect((await f.create()).status).toBe(202);
f.resources.push({ ...f.resources[0], id: "conflicting-resource" });
expect((await f.create()).status).toBe(409);
expect(f.counts().creates).toBe(1);
});
it("does not adopt a different daemon's resource with the same user operation name", async () => {
const f = fixture();
f.resources.push({ id: "foreign-resource", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" });
expect((await f.create()).status).toBe(409); expect(f.counts().creates).toBe(0);
expect((await f.request("/binding-one")).body.resource).toBeNull();
});
it("does not reuse an operation identity with different placement", async () => {
const f = fixture(); expect((await f.create()).status).toBe(201);
expect((await f.request("/create", { ...f.input, placement: { ...f.input.placement, name: "different" } })).status).toBe(409);
expect(f.counts().creates).toBe(1);
});
it("attaches only the exact live ID and retains external ownership", async () => {
const f = fixture(); f.resources.push({ id: "external-id", name: "worker-one", workspace: "workspace", fingerprint: "host-key-1", phase: "Ready" });
const result = await f.request("/attach", { ...f.input, operationId: "attach-one", resourceId: "external-id", fingerprint: "host-key-1" });
expect(result.status).toBe(201);
expect(result.body).toMatchObject({ ownership: "attached", resource: { id: "external-id" } });
expect(f.counts().creates).toBe(0);
expect((await f.request("/binding-one/effect", { effect: "delete_environment" })).status).toBe(404);
expect(f.resources[0].id).toBe("external-id");
});
it("refuses a reused resource name with a different immutable ID", async () => {
const f = fixture(); f.resources.push({ id: "reused-id", name: "worker-one", workspace: "workspace", phase: "Ready", fingerprint: "host-key-1" });
const result = await f.request("/attach", { ...f.input, operationId: "attach-one", resourceId: "expected-id", fingerprint: "host-key-1" });
expect(result.status).toBe(409); expect(f.counts().creates).toBe(0);
});
it("verifies explicit destination bytes and refuses a changed required projection", async () => {
const f = fixture(); expect((await f.create()).status).toBe(201);
const local = join(f.root, "role.md"); writeFileSync(local, "current guidance");
const result = await f.project({ operationId: "projection-one", generation: "generation-one",
files: [{ source: local, path: "work/CLAUDE.md", sha256: hash("current guidance"), required: true }] });
expect(result.status).toBe(200);
f.files.set("/sandbox/openrig/work/CLAUDE.md", "changed after projection");
expect((await f.request("/binding-one/validate", { generation: "generation-one" })).status).toBe(409);
});
it("preserves an unmanaged destination conflict without publishing a prepared latch", async () => {
const f = fixture(); expect((await f.create()).status).toBe(201);
const local = join(f.root, "role.md"); writeFileSync(local, "current guidance");
f.files.set("/sandbox/openrig/work/CLAUDE.md", "owner content");
expect((await f.project({ operationId: "projection-one", generation: "generation-one",
files: [{ source: local, path: "work/CLAUDE.md", sha256: hash("current guidance"), required: true }] })).status).toBe(409);
expect(f.files.get("/sandbox/openrig/work/CLAUDE.md")).toBe("owner content");
});
it("rejects projection traversal before a provider write", async () => {
const f = fixture(); expect((await f.create()).status).toBe(201);
const local = join(f.root, "role.md"); writeFileSync(local, "current guidance");
expect((await f.project({ operationId: "projection-one", generation: "generation-one",
files: [{ source: local, path: "../outside", sha256: hash("current guidance"), required: true }] })).status).toBe(400);
expect(f.files.size).toBe(0);
});
it("keeps the ordinary host health route available without an environment selection", async () => {
const f = fixture(); expect((await f.health()).status).toBe(200); expect((await f.request("/missing")).status).toBe(404);
});
});
describe("managed environment artifact return", () => {
async function prepared() {
const f = fixture(); expect((await f.create()).status).toBe(201); await f.task();
expect((await f.project({ operationId: "projection-one", generation: "generation-one", files: [] })).status).toBe(200);
const body = JSON.stringify({ task: "task-one", revision: 2, report: "actual artifact bytes" });
f.files.set("/sandbox/openrig/work/artifacts/report.json", body);
const input = { operationId: "export-one", taskId: "task-one", generation: "generation-one", path: "report.json", sha256: hash(body), bytes: Buffer.byteLength(body),
schema: { format: "json-object/v1", fields: { task: "string", revision: "number", report: "string" }, required: ["task", "revision", "report"] },
context: { task: "task-one", revision: 2 } };
return { ...f, body, exportInput: input, export: () => f.request("/binding-one/export", input) };
}
it("publishes verified bytes once and appends a durable receipt to the same work row", async () => {
const f = await prepared(), result = await f.export();
expect(result.status).toBe(200);
expect(result.body).toMatchObject({ version: 1, bindingId: "binding-one", taskId: "task-one", generation: "generation-one",
sha256: hash(f.body), bytes: Buffer.byteLength(f.body), actor: "coordinator@environment-fixture" });
expect(readFileSync(result.body.path, "utf8")).toBe(f.body);
const notes = f.db.prepare("SELECT transition_note FROM queue_transitions WHERE qitem_id = 'task-one'").all() as { transition_note: string }[];
expect(notes.filter(n => n.transition_note.includes("export-one"))).toHaveLength(1);
const replay = await f.export(); expect(replay.status).toBe(200);
expect(replay.body).toEqual(result.body); expect(f.counts().downloads).toBe(1);
});
it("reconciles host publication before receipt after restart without requiring the guest", async () => {
const f = await prepared();
const canonical = (value: unknown) => JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v);
f.db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state) VALUES ('export-one','binding-one','export',?,'requested')").run(canonical(f.exportInput));
mkdirSync(join(f.root, "exports", "binding-one"));
writeFileSync(join(f.root, "exports", "binding-one", "task-one-export-one.json"), f.body);
f.files.clear(); f.restart();
const result = await f.export(); expect(result.status).toBe(200);
expect(result.body.sha256).toBe(hash(f.body)); expect(f.counts().downloads).toBe(0);
const notes = f.db.prepare("SELECT transition_note FROM queue_transitions WHERE qitem_id = 'task-one'").all() as { transition_note: string }[];
expect(notes.filter(n => n.transition_note.includes("export-one"))).toHaveLength(1);
});
it("refuses changed host bytes on replay rather than overwriting them", async () => {
const f = await prepared(); const first = await f.export(); expect(first.status).toBe(200);
writeFileSync(first.body.path, "changed owner bytes");
expect((await f.export()).status).toBe(409); expect(readFileSync(first.body.path, "utf8")).toBe("changed owner bytes");
});
it("refuses a matching hash whose task context does not match the selected contract", async () => {
const f = await prepared(); f.exportInput.context.revision = 3;
expect((await f.export()).status).toBe(409);
expect(f.db.prepare("SELECT receipt_json FROM environment_operations WHERE operation_id='export-one'").get()).toMatchObject({ receipt_json: null });
});
it("refuses stale generation before download or publication", async () => {
const f = await prepared(); f.exportInput.generation = "older-generation";
expect((await f.export()).status).toBe(409); expect(f.counts().downloads).toBe(0);
});
it("never exports native state or a path outside the artifact root", async () => {
const f = await prepared(); f.exportInput.path = "../../native/history.jsonl";
expect((await f.export()).status).toBe(400); expect(f.counts().downloads).toBe(0);
});
});
it("allows a corrected attach after a refused immutable identity", async () => {
const f = fixture(); f.resources.push({ id: "correct-id", name: "worker-one", workspace: "workspace", phase: "Ready" });
expect((await f.request("/attach", { ...f.input, operationId: "bad-attach", resourceId: "wrong-id" })).status).toBe(409);
const result = await f.request("/attach", { ...f.input, operationId: "correct-attach", resourceId: "correct-id" });
expect(result.status).toBe(201); expect(result.body.resource.id).toBe("correct-id");
});
it("allows a corrected create when the old binding has never requested a provider effect", async () => {
const f = fixture(); f.resources.push({ id: "foreign", name: "worker-one", workspace: "workspace", phase: "Ready" });
expect((await f.create()).status).toBe(409);
const result = await f.request("/create", { ...f.input, operationId: "correct-create", placement: { ...f.input.placement, name: "worker-two" } });
expect(result.status).toBe(201); expect(f.counts().creates).toBe(1);
});
it("reports an already-bound seat as a typed conflict", async () => {
const f = fixture(); expect((await f.create()).status).toBe(201);
const result = await f.request("/create", { ...f.input, bindingId: "other-binding", operationId: "other-create" });
expect(result.status).toBe(409); expect(result.body.code).toBe("environment_seat_bound");
});
@@ -0,0 +1,99 @@
import { afterEach, expect, it, vi } from "vitest";
import { mkdtempSync, realpathSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { EnvironmentError, EnvironmentPlatform, sha256 } from "../src/domain/environment-platform.js";
import { EnvironmentStartup } from "../src/domain/environment-startup.js";
import { OpenShellDriver } from "../src/adapters/openshell-driver.js";
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
const cleanup: Array<() => unknown> = [];
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
async function fixture() {
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-startup-")));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const db = createFullTestDb(); cleanup.push(() => db.close());
const app = createTestApp(db), rig = app.rigRepo.createRig("startup-fixture");
const node = app.rigRepo.addNode(rig.id, "team.worker", { runtime: "openshell-claude", cwd: root });
const files = new Map<string, Buffer>(), resource = { id: "resource-one", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
let native: Record<string, unknown> | undefined, losePrepare = false;
const guest = vi.fn(async (_binding: unknown, args: string[]) => {
if (args[0] === "--prepare") {
native = JSON.parse(files.get("/sandbox/openrig/control/launch.pending.json")!.toString());
if (losePrepare) throw new EnvironmentError("openshell_transport_unconfirmed", "Lost preparation reply");
return { ok: true, ...native };
}
if (!native) throw new EnvironmentError("environment_native_unconfirmed", "Native is not observed");
return { ok: true, ...native, pid: 1 };
});
// The fixed CLI boundary is replaced; no OpenShell executable or provider is invoked.
const driver = Object.assign(new OpenShellDriver(async () => { throw new Error("unexpected vendor invocation"); }), {
list: async () => ({ complete: true, resources: [] }), create: async (input: { operationId: string }) => { resource.operationId = input.operationId; return resource; }, inspect: async () => resource,
upload: async (_binding: unknown, source: string, destination: string) => { files.set(destination, readFileSync(source)); },
read: async (_binding: unknown, path: string) => { const data = files.get(path); if (!data) throw new EnvironmentError("environment_file_missing", "No file"); return data; },
guest,
});
const platform = new EnvironmentPlatform(db, driver), policy = join(root, "policy.yaml"), connection = join(root, "work.json");
writeFileSync(policy, "version: 1\n");
writeFileSync(connection, JSON.stringify({ version: 1, seat: "team-worker@startup-fixture", mcpUrl: "http://guest.test:7781/mcp", token: "fixture-work-token" }), { mode: 0o600 });
await platform.create({ version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id, seat: "team-worker@startup-fixture", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7782", coordinationRef: connection,
placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } });
const activity = vi.fn(async () => ({ grantId: "grant-one", env: { OPENRIG_ACTIVITY_HOOK_TOKEN: "scoped-fixture-token" } }));
const startup = new EnvironmentStartup(platform, join(root, "stage"), activity);
const binding = { nodeId: node.id, tmuxSession: "team-worker@startup-fixture", cwd: root, launchGeneration: "generation-one" } as NodeBinding;
const plan = { runtime: "openshell-claude", cwd: root, entries: [], conflicts: [], noOps: [], diagnostics: [], startup: { files: [], actions: [] } } as ProjectionPlan;
const tmux = { sendShellCommand: vi.fn(async () => ({ ok: true as const })) };
await startup.project(plan, binding);
return { root, db, files, startup, platform, activity, guest, tmux, binding, losePrepare: () => { losePrepare = true; } };
}
it("stages all resolved startup inputs before native dispatch and binds separate work/activity connections", async () => {
const f = await fixture(), role = join(f.root, "role.md"); writeFileSync(role, "Current nonce and useful task guidance");
await f.startup.deliverStartup([{ path: "role.md", absolutePath: role, ownerRoot: f.root, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }], f.binding);
const result = await f.startup.launch(f.binding, {}, f.tmux);
expect(result.ok).toBe(true);
expect(f.files.get("/sandbox/openrig/work/.openrig/startup/role.md")!.toString()).toBe("Current nonce and useful task guidance");
expect(f.files.get("/sandbox/openrig/work/CLAUDE.md")!.toString()).toContain("Read /sandbox/openrig/work/.openrig/startup/role.md");
expect(f.activity).toHaveBeenCalledWith({ nodeId: f.binding.nodeId, generation: "generation-one", seat: "team-worker@startup-fixture", guestBaseUrl: "http://guest.test:7782" });
const coordination = f.files.get("/sandbox/openrig/control/coordination.json")!.toString();
expect(coordination).toContain("fixture-work-token"); expect(coordination).not.toContain("scoped-fixture-token");
const receipt = f.db.prepare("SELECT receipt_json FROM environment_operations WHERE kind='launch'").get() as { receipt_json: string };
expect(receipt.receipt_json).toContain("grant-one"); expect(receipt.receipt_json).not.toContain("fixture-token");
expect(f.tmux.sendShellCommand).toHaveBeenCalledTimes(1);
});
it("reconciles a lost latch reply without issuing another grant or rewriting an execution", async () => {
const f = await fixture(); f.losePrepare();
await expect(f.startup.launch(f.binding, {}, f.tmux)).rejects.toThrow("Lost preparation reply");
const before = sha256(f.files.get("/sandbox/openrig/control/launch.pending.json")!);
expect((await f.startup.launch(f.binding, {}, f.tmux)).ok).toBe(true);
expect(f.activity).toHaveBeenCalledTimes(1); expect(f.guest.mock.calls.filter(call => call[1][0] === "--prepare")).toHaveLength(1);
expect(sha256(f.files.get("/sandbox/openrig/control/launch.pending.json")!)).toBe(before);
});
it("blocks a changed required source before issuing a grant or preparing native", async () => {
const f = await fixture(), role = join(f.root, "role.md"); writeFileSync(role, "selected source");
await f.startup.deliverStartup([{ path: "role.md", absolutePath: role, ownerRoot: f.root, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }], f.binding);
writeFileSync(role, "changed after resolution");
await expect(f.startup.launch(f.binding, {}, f.tmux)).rejects.toMatchObject({ code: "environment_projection_source_changed" });
expect(f.activity).not.toHaveBeenCalled(); expect(f.guest).not.toHaveBeenCalled(); expect(f.tmux.sendShellCommand).not.toHaveBeenCalled();
});
it("refuses an exact-resume request without launching a fresh native", async () => {
const f = await fixture(), result = await f.startup.launch(f.binding, { resumeToken: "saved-conversation" }, f.tmux);
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
expect(f.activity).not.toHaveBeenCalled(); expect(f.tmux.sendShellCommand).not.toHaveBeenCalled();
});
it("refuses a new generation before projecting over a saved execution", async () => {
const f = await fixture(); expect((await f.startup.launch(f.binding, {}, f.tmux)).ok).toBe(true);
const before = [...f.files].map(([key,value]) => [key,value.toString("base64")]);
const generation = f.platform.get("binding-one")!.generation;
await expect(f.startup.project({ entries: [] } as unknown as ProjectionPlan, { ...f.binding, launchGeneration: "generation-two" })).rejects.toMatchObject({ code: "environment_prior_execution_unconfirmed" });
expect([...f.files].map(([key,value]) => [key,value.toString("base64")])).toEqual(before);
expect(f.platform.get("binding-one")!.generation).toBe(generation);
});
it("refuses startup input replacement before touching a saved execution", async () => {
const f = await fixture(); expect((await f.startup.launch(f.binding, {}, f.tmux)).ok).toBe(true);
const before = [...f.files].map(([key,value]) => [key,value.toString("base64")]);
await expect(f.startup.deliverStartup([], f.binding)).rejects.toMatchObject({ code: "environment_prior_execution_unconfirmed" });
expect([...f.files].map(([key,value]) => [key,value.toString("base64")])).toEqual(before);
});
@@ -0,0 +1,48 @@
import { afterEach, expect, it } from "vitest";
import { serve } from "@hono/node-server";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createGuestActivityApp, type AppDeps } from "../src/server.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
const cleanups: Array<() => void | Promise<void>> = [];
afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close(); });
it("confines the actual guest HTTP port to grant-bound hooks", async () => {
const db = createFullTestDb(); cleanups.push(() => db.close());
const rigs = new RigRepository(db), rig = rigs.createRig("guest-fixture");
const node = rigs.addNode(rig.id,"team.worker",{runtime:"synth"});
const sessions = new SessionRegistry(db), eventBus = new EventBus(db);
const seat = "team-worker@guest-fixture";
const session = sessions.registerSession(node.id,seat); sessions.updateStatus(session.id,"running"); sessions.recordCommunityHarnessSelection(session.id,"synth",true);
const store = new AgentActivityStore({db,eventBus,resolveOccupantGeneration: id => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
isRegisteredOccupantGeneration:(id,g) => sessions.isOccupantGenerationRegistered(id,g)});
const app = createGuestActivityApp({rigRepo:rigs,sessionRegistry:sessions,eventBus,agentActivityStore:store, activityHookToken:"install-token"} as AppDeps);
const server = serve({fetch:app.fetch,hostname:"127.0.0.1",port:0});
cleanups.push(() => new Promise<void>((done,reject) => server.close(error => error ? reject(error) : done())));
if (!server.listening) await new Promise<void>(done => server.once("listening",done));
const address = server.address(); if (!address || typeof address === "string") throw new Error("No test port");
const origin = `http://127.0.0.1:${address.port}`, grants = new ActivityGrantStore(db);
const issue = () => grants.issue({nodeId:node.id,generation:sessions.currentOccupantTenure(node.id)!.generationUuid,issuedBy:"test"});
const post = (token:string,body:Record<string,unknown>={}) => fetch(origin+"/api/activity/hooks",{method:"POST",headers:{authorization:`Bearer ${token}`,"content-type":"application/json"},
body:JSON.stringify({runtime:"synth",hookEvent:"UserPromptSubmit",subtype:"accepted",occurredAt:new Date().toISOString(),...body})});
const grant=issue(); expect((await post(grant.token)).status).toBe(200);
expect((await post(grant.token,{sessionName:"other@guest-fixture"})).status).toBe(403);
expect((await post("install-token")).status).toBe(401);
for (const [method,path] of [["GET","/api/config"],["POST","/api/queue/create"],["GET","/api/activity/events"],["GET","/api/activity/parked"],["POST","/api/activity/pi-usage"],["POST","/api/activity/pi-usage-charges"],["GET","/healthz"]]) {
const response=await fetch(origin+path,{method,headers:{authorization:`Bearer ${grant.token}`,"content-type":"application/json"},...(method === "POST" ? {body:"{}"} : {})});
expect(response.status,`${method} ${path}`).toBe(404);
}
sessions.registerSession(node.id,seat,"handover");
expect((await post(grant.token)).status).toBe(409);
const next=issue(); grants.revoke({grantId:next.grantId},"test");
expect((await post(next.token)).status).toBe(401);
});
it("the shipped provider policy exposes the two confined ports and no main API port", () => {
const policy=readFileSync(resolve(import.meta.dirname,"../assets/openshell/policy.yaml"),"utf8");
expect(policy).toContain("port: 7781"); expect(policy).toContain("port: 7782"); expect(policy).not.toContain("7433");
});
+3 -1
View File
@@ -1,3 +1,5 @@
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
import { nodeAccountHomeSchema } from "../../src/db/migrations/141_node_account_home.js";
import { escalationTargetRefsSchema } from "../../src/db/migrations/126_escalation_target_refs.js";
import { nodeClaudePermissionsSchema } from "../../src/db/migrations/139_node_claude_permissions.js";
@@ -153,7 +155,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -0,0 +1,51 @@
import { afterEach, expect, it, vi } from "vitest";
import { mkdtempSync, realpathSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { OpenShellDriver } from "../src/adapters/openshell-driver.js";
import { sha256, type Placement, type EnvironmentBinding } from "../src/domain/environment-platform.js";
const cleanup: Array<() => void> = [];
afterEach(() => { for (const close of cleanup.splice(0)) close(); });
it("reads pinned top-level list labels and treats pagination as incomplete", async () => {
const command = vi.fn(async () => JSON.stringify({ next_page_token: "second-page", sandboxes: [{ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready", host_key_fingerprint: "fingerprint", labels: { openrigoperation: "operation-one" } }] }));
const result = await new OpenShellDriver(command).list({ gateway: "gateway", workspace: "workspace" } as Placement);
expect(result).toEqual({ complete: false, resources: [{ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready", fingerprint: "fingerprint", operationId: "operation-one" }] });
expect(command.mock.calls[0][0]).toEqual(["--gateway", "gateway", "--workspace", "workspace", "sandbox", "list", "--output", "json", "--page-size", "100"]);
});
it("refuses changed provider policy bytes before a create command", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-policy-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const policy = join(root, "policy.yaml"); writeFileSync(policy, "changed policy");
const command = vi.fn(async () => "{}");
await expect(new OpenShellDriver(command).create({ operationId: "operation-one", placement: { gateway: "g", workspace: "w", name: "worker-one", image: "image", provider: "provider", policy, policySha256: sha256("selected policy") } })).rejects.toMatchObject({ code: "environment_policy_changed" });
expect(command).not.toHaveBeenCalled();
});
it("preserves a typed missing-file result from the fixed guest helper", async () => {
const command = vi.fn(async (args: string[]) => args.includes("get")
? JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready", labels: {} })
: JSON.stringify({ ok: false, code: "environment_file_missing" }));
const binding = { guestRoot: "/sandbox/openrig", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding;
await expect(new OpenShellDriver(command).read(binding, "/sandbox/openrig/work/role.md")).rejects.toMatchObject({ code: "environment_file_missing" });
});
it("ingests bounded raw bytes instead of vendor tar after a growing stat", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-ingest-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
let vendorDownloads = 0;
const command = vi.fn(async (args: string[]) => {
if (args.includes("get")) return JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready", labels: {} });
if (args.includes("download")) { vendorDownloads++; writeFileSync(join(root, "artifact.json"), Buffer.alloc(1024*1024+1)); return ""; }
if (args.includes("--stat")) return JSON.stringify({ ok: true, bytes: 2 });
return JSON.stringify({ ok: true, base64: Buffer.alloc(1024*1024+1).toString("base64") });
});
const binding = { guestRoot: "/sandbox/openrig", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding;
await expect(new OpenShellDriver(command).download(binding, "/sandbox/openrig/work/artifacts/report.json", join(root,"artifact.json"))).rejects.toMatchObject({ code: "environment_file_invalid" });
expect(vendorDownloads).toBe(0); expect(existsSync(join(root,"artifact.json"))).toBe(false);
});
it("transfers one bounded file without extracting a vendor archive", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-ingest-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const command = vi.fn(async (args: string[]) => args.includes("get") ? JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready" })
: JSON.stringify({ ok: true, base64: Buffer.from('{"ok":true}').toString("base64") }));
const binding = { guestRoot: "/sandbox/openrig", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding;
await new OpenShellDriver(command).download(binding, "/sandbox/openrig/work/artifacts/report.json", join(root,"artifact.json"));
expect(readFileSync(join(root,"artifact.json"),"utf8")).toBe('{"ok":true}');
expect(command.mock.calls.some(([args]) => args.includes("download"))).toBe(false);
});
@@ -0,0 +1,53 @@
import { afterEach, expect, it } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtempSync, realpathSync, mkdirSync, readFileSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const run = promisify(execFile), roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
const helper = resolve(import.meta.dirname, "../assets/openshell/guest.py");
async function command(root: string, ...args: string[]) {
const result = await run("python3", [helper, "--root", root, ...args], { timeout: 5000, env: { PATH: process.env.PATH ?? "/usr/bin:/bin" } });
return JSON.parse(result.stdout);
}
it("uses the shipped helper to verify installed bytes and preserve an unmanaged destination", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-helper-"))); roots.push(root);
mkdirSync(join(root, "control")); mkdirSync(join(root, "work"));
writeFileSync(join(root, "control", "transfer"), "required current bytes");
expect(await command(root, "--install", join(root, "control", "transfer"), "--destination", join(root, "work", "role.md"), "--previous", "missing")).toMatchObject({ ok: true, bytes: 22 });
expect((await command(root, "--read", join(root, "work", "role.md"))).base64).toBe(Buffer.from("required current bytes").toString("base64"));
writeFileSync(join(root, "control", "transfer-two"), "replacement");
expect(await command(root, "--install", join(root, "control", "transfer-two"), "--destination", join(root, "work", "role.md"), "--previous", "missing")).toMatchObject({ ok: false, code: "environment_projection_conflict" });
expect(readFileSync(join(root, "work", "role.md"), "utf8")).toBe("required current bytes");
});
it("rejects traversal and symlink reads instead of copying native or owner files", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-helper-"))); roots.push(root);
writeFileSync(join(root, "outside"), "preserved"); mkdirSync(join(root, "work"));
symlinkSync(join(root, "outside"), join(root, "work", "link"));
expect(await command(root, "--read", join(root, "work", "link"))).toMatchObject({ ok: false, code: "environment_symlink" });
expect(await command(root, "--read", root + "/../outside")).toMatchObject({ ok: false });
});
it("caps a file that grows after descriptor stat without reading its full content", async () => {
const root=realpathSync(mkdtempSync(join(tmpdir(),"openshell-helper-"))); roots.push(root);
mkdirSync(join(root,"work")); const file=join(root,"work","report.json"); writeFileSync(file,"{}");
const script=`import importlib.util,os,sys
spec=importlib.util.spec_from_file_location("guest",sys.argv[1]); g=importlib.util.module_from_spec(spec); spec.loader.exec_module(g)
original=os.fstat
changed=False
def race(fd):
global changed
value=original(fd)
if not changed:
changed=True
with open(sys.argv[3],"ab") as stream: stream.write(b"x"*(g.LIMIT+1))
return value
os.fstat=race
try:
g.regular(sys.argv[2],sys.argv[3]); print("unexpected-success")
except g.Refused as error: print(str(error))
`;
const result=await run("python3",["-c",script,helper,root,file],{timeout:5000});
expect(result.stdout.trim()).toBe("environment_file_invalid");
});
@@ -55,6 +55,10 @@ import { join } from "node:path";
* label is the point: adding a writer means stating what it does, not just
* silencing a checker. */
const SANCTIONED: Record<string, { sites: number; why: string }> = {
"domain/environment-artifacts.ts": {
sites: 1,
why: "verified artifact publication: one non-terminal work receipt note in the same transaction as the durable export receipt; no task closure, notifications after commit",
},
"domain/mission-control/mission-control-write-contract.ts": {
sites: 2,
why: "P34 terminal close + successor create (route/handoff) at :160; the non-terminal resolve update at :415",
@@ -210,9 +214,9 @@ describe("P34 RED 3 — the enumeration guard (rev 2: call-site granularity)", (
it("the LIVE corpus matches the sanctioned set exactly, by CALL SITE", () => {
const result = checkCallSites(realCorpus(), SANCTIONED);
expect(result.violations).toEqual([]);
// Counts SITES, not files. 14 sites across 5 files.
// Counts SITES, not files. 15 sites across 6 files.
expect(result.examined).toBe(TOTAL_SITES);
expect(result.examined).toBe(14);
expect(result.examined).toBe(15);
expect(result.vacuous).toBe(false);
});
@@ -316,6 +320,6 @@ describe("P34 RED 5 — KNOWN-NEGATIVE: a guard that examines nothing must FAIL"
it("the LIVE run is NOT vacuous and examined the expected number of SITES", () => {
const live = checkCallSites(realCorpus(), SANCTIONED);
expect(live.vacuous).toBe(false);
expect(live.examined).toBe(14);
expect(live.examined).toBe(15);
});
});
@@ -875,6 +875,18 @@ describe("StartupOrchestrator", () => {
expect(tmux.sendText).not.toHaveBeenCalled();
});
it("does not mark pending or project when saved environment execution refuses the prelude", async () => {
const seed=seedSession();
sessionRegistry.updateStartupStatus(seed.sessionId,"ready");
const preflight=vi.fn(async () => { throw new Error("saved execution preserved"); });
const adapter=mockAdapter({runtime:"openshell-claude",environment:{} as RuntimeAdapter["environment"],preflightEnvironmentStartup:preflight});
const input=makeInput(seed,{adapter});
const result=await createOrchestrator().startNode(input);
expect(result).toMatchObject({ok:false,startupStatus:"attention_required",errors:["saved execution preserved"]});
expect(preflight).toHaveBeenCalledTimes(1); expect(adapter.project).not.toHaveBeenCalled(); expect(adapter.launchHarness).not.toHaveBeenCalled();
expect(db.prepare("SELECT startup_status FROM sessions WHERE id=?").get(seed.sessionId)).toEqual({startup_status:"ready"});
});
// T1: fresh launch enters pending before startup delivery
it("marks pending before delivery", async () => {
const seed = seedSession();