Compare commits

...
Author SHA1 Message Date
dev-guard 3c4c4c49f8 test: model the host viewer pane in normal restore controls 2026-10-30 08:26:34 +00:00
dev-guard 076e655896 test: install maintained preset for normal restore controls 2026-10-30 07:49:09 +00:00
dev-guard 1521cc765c test: retain predecessor host before restore context controls 2026-10-30 06:57:59 +00:00
dev-guard 45b3bb8773 test: exercise current destination context through normal restore 2026-10-30 06:07:46 +00:00
dev-guard d730983ba3 test(daemon): fix lifecycle regression premises and name requester attribution honestly 2026-10-30 06:03:40 +00:00
dev-guard 6a28f9a4f3 Merge current OpenShell platform dependency into lifecycle corrections 2026-10-30 05:41:59 +00:00
dev-guard ddb78da24a test(daemon): exercise attached preservation through the real effect route 2026-10-30 05:41:45 +00:00
dev-guard 726bbfa571 fix(daemon): restore contained context and guard completion dispatch 2026-10-30 05:39:05 +00:00
dev-guard 6a208dd62e test(daemon): cover recreated context and completion dispatch races 2026-10-30 05:32:22 +00:00
dev-guard 9a3d893cfc Merge main into OpenShell platform integration 2026-10-30 05:20:20 +00:00
dev-guard 558178d1da fix(daemon): import community harness identity from its canonical module 2026-10-30 05:11:13 +00:00
dev-guard 40ae937d24 test(cli): align env help assertion with execution placement guidance 2026-10-30 05:10:00 +00:00
dev-guard 267ed1c254 fix: narrow retained engine identities and complete lifecycle imports 2026-10-30 05:02:54 +00:00
dev-guard d4d19d9cfb fix: require retained history at the native resume latch 2026-10-30 05:00:25 +00:00
dev-guard 552e74a090 feat: retain exact native state and scope selected completion effects 2026-10-30 04:58:57 +00:00
dev-guard 6dcf251eaa Merge confined ingress and early startup reconciliation into lifecycle draft 2026-10-30 04:55:11 +00:00
dev-guard 69832f71a8 feat: draft exact environment lifecycle and retained native restore 2026-10-30 04:50:57 +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
55 changed files with 3306 additions and 31 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. Explicit owned lifecycle operations retain separate observed receipts. Exact same-host recreate/restore requires verified private history and the selected checkpoint; attached native, storage and compute are preserved. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
+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
+169
View File
@@ -0,0 +1,169 @@
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.command("checkpoint").argument("<binding>").requiredOption("--generation <id>", "Selected current execution generation")
.requiredOption("--operation <id>", "Stable private checkpoint identity").option("--json")
.description("Capture exact private native history and verified prior artifact references while the provider remains accessible")
.action(action(async (id: string, opts: { generation: string; operation: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/checkpoint`, { operationId: opts.operation, generation: opts.generation }, { timeoutMs: 120_000 })))));
cmd.command("recreate").argument("<binding>").requiredOption("--request <path>", "JSON with operationId, predecessor generation, checkpointId, exact resumeType/resumeToken and same-host placement")
.option("--json").description("Explicitly create successor compute only after exact predecessor cleanup and retained state verification; native launch remains normal rig restore")
.action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/recreate`, JSON.parse(readFileSync(opts.request, "utf8")), { timeoutMs: 120_000 })))));
cmd.command("completion").argument("<binding>").requiredOption("--request <path>", "JSON selecting operationId, taskId, generation, exportOperationId and stop-native or stop-compute")
.option("--json").description("Select finite completion for one task, generation and required verified export; persistent defaults stay unchanged")
.action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/completion`, JSON.parse(readFileSync(opts.request, "utf8")))))));
cmd.command("effect").argument("<binding>").requiredOption("--effect <name>", "close-viewer, stop-native, stop-compute or delete-owned")
.requiredOption("--generation <id>", "Selected execution generation").requiredOption("--operation <id>", "Stable effect identity; retries reconcile without another provider mutation")
.option("--engine-socket <path>", "Owned local rootless Podman socket, required to capture dependent identities before delete-owned").option("--json")
.description("Execute one selected effect and return its separate observed outcome; attached native/storage/compute are preserved")
.action(action(async (id: string, opts: { effect: string; generation: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation, operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 })))));
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. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n");
return cmd;
}
+3
View File
@@ -1323,6 +1323,8 @@ session is refused (use rig unclaim). Stopping ends that agent's session and any
work in progress: check it first (rig capture <seat>) and stop only a seat you
own or were asked to stop. rig seat launch <seat> --fresh --reason <text> starts
a fresh occupant there; it does not resume the stopped conversation.
A community harness stop closes its managed pane; its native processes or compute
may outlive it. Inspect their own lifecycle receipts.
An intentionally stopped seat does not degrade an otherwise running rig.
Running counts still exclude the stopped seat; stopping every seat leaves the
rig stopped. Examples:
@@ -1333,6 +1335,7 @@ rig stopped. Examples:
const s = data["seat"] as { logicalId?: string; rigName?: string } | undefined;
console.log(`Stopped ${String(data["sessionName"])} (seat ${s?.logicalId}@${s?.rigName}).`);
console.log("Session marked exited, binding cleared; siblings untouched. Relaunch via the normal launch surface.");
if (data["paneOnly"]) console.log("Only the community seat viewer was closed. Native execution, storage and compute may remain; inspect the selected environment or harness lifecycle.");
});
});
+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",
+3 -3
View File
@@ -124,14 +124,14 @@ describe("rig env", () => {
return prog;
}
it("env help explains managed-app usage", () => {
it("env help explains services and execution placement", () => {
const logs: string[] = [];
const cmd = makeCmd().commands.find((c) => c.name() === "env")!;
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Inspect and control rig environment services");
expect(help).toContain("service-backed rigs and managed");
expect(help).toContain("Inspect and control rig services");
expect(help).toContain("for execution placement use rig environment");
expect(help).toContain("rig env status secrets-manager");
expect(help).toContain("rig env logs secrets-manager vault");
});
@@ -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"]
+126
View File
@@ -0,0 +1,126 @@
# 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.
## Select completion and retain native state
Finite completion is optional and belongs to one binding input. It does not change AgentSpec or persistent defaults. Save a request file selecting exactly one task, generation, required export operation and effect:
```json
{"operationId":"complete-task-one","taskId":"YOUR_TASK_ID","generation":"YOUR_OCCUPANT_GENERATION","exportOperationId":"report-task-one","effect":"stop-native"}
```
```sh
rig environment completion worker-one --request completion.json --json
rig environment checkpoint worker-one --generation YOUR_OCCUPANT_GENERATION --operation native-checkpoint-one --json
```
A selected task must be successfully settled under the existing queue closure contract, with its required host export still matching the verified bytes. Only then does the selected native/compute stop run. Failed work, another row closing, an empty queue, a closed viewer, a stale generation and a pending/changed export do not count. The task, seat and native identity remain. Receipt and pending-effect state are visible in `inspect`. Repeating the same completion request reconciles it without a second provider mutation.
Checkpoint while the guest is accessible. Export the useful result first, then capture the exact newline-terminated native history prefix and prior verified artifact references. A later native append does not rewrite that checkpoint. This first native-store mapping supports one history file of at most 1 MiB at the fixed Claude project path, not arbitrary native-store portability. Retained prior artifacts are bounded to 128 files and 32 MiB combined. Private history bytes stay under the daemon's private state root; snapshots carry only binding/checkpoint/native/compute/state-location references. Missing, changed or mismatched state refuses resume, never starts fresh.
The guest supervisor retains control/file access for up to 30 minutes after native exit. Its native PID and process-start identity are distinct from the supervisor and compute identity. An idle supervisor is not an idle model claim. Native TERM has its own bounded observation; it does not delete compute. Collect the checkpoint before stopping compute.
## Distinct selected effects
```sh
rig environment effect worker-one --effect close-viewer --generation YOUR_OCCUPANT_GENERATION --operation detach-one --json
rig environment effect worker-one --effect stop-native --generation YOUR_OCCUPANT_GENERATION --operation native-stop-one --json
rig environment effect worker-one --effect stop-compute --generation YOUR_OCCUPANT_GENERATION --operation compute-stop-one --json
rig environment effect worker-one --effect delete-owned --generation YOUR_OCCUPANT_GENERATION --operation delete-one --engine-socket /run/user/YOUR_UID/podman/podman.sock --json
```
Use the actual local rootless Podman socket on the provider host. The observer is read-only; it never removes engine resources itself. Before deletion it records exact workload/supervisor container identities and their attributable workspace/control/channel volumes. After the single vendor request, a complete gateway listing must establish the immutable resource's absence, and independent queries on that same engine/socket must establish those components absent. Other engine resources are preserved. Transport/auth errors, changed sockets, incomplete lists or a remaining volume stay unconfirmed. A vendor Accepted/Completed response alone does not establish engine cleanup. Per-sandbox secret cleanup is not an observed component class here.
Attached consumers may close only their own viewer. Their external native process, storage and compute remain preserved. `rig seat stop` and `rig down` retain their existing pane semantics and label community panes accordingly; neither stands in for these explicit resource receipts. A pending/unconfirmed effect retains its original operation. Repeating that operation observes its result without blindly repeating stop/delete. A reused name with another immutable ID refuses deletion.
## Explicit same-host compute recreation and exact restore
After checkpoint and confirmed predecessor deletion, save a recreate request using the checkpoint receipt's exact native type/id and predecessor generation, plus the selected successor placement:
```json
{
"operationId":"recreate-one", "generation":"YOUR_PREDECESSOR_GENERATION",
"checkpointId":"native-checkpoint-one", "resumeType":"community:openshell-claude", "resumeToken":"YOUR_RETAINED_NATIVE_UUID",
"placement":{"gateway":"YOUR_GATEWAY","workspace":"YOUR_WORKSPACE","name":"worker-two","image":"YOUR_IMAGE@sha256:YOUR_64_HEX_DIGEST","provider":"YOUR_CURRENT_PROVIDER","policy":"/ABSOLUTE/PATH/TO/policy.yaml"}
}
```
```sh
rig environment recreate worker-one --request recreate.json --json
rig restore --help
```
Use normal `rig restore` or its existing selected-seat restore surface for the same team snapshot. The snapshot must carry the exact binding/checkpoint/native references. Recreation creates compute only: it does not choose a fresh native UUID or send model input. Current provider references may be selected; old revoked credential bytes are never restored. The successor must be observed on the retained predecessor host/engine before its binding advances. Its first actual launch generation is claimed once, then current projection, unchanged history and prior artifact bytes are verified before the descriptor is released. Native launch uses `--resume` with the exact saved UUID. No missing-history, different-type or restore failure permits a fresh fallback.
The first source tests use fake provider transport, the actual guest helper's file operations and real daemon/MCP/filesystem paths. They do not establish native authentication, ingestion, blind recall, RAM release, same-container restart or a successful provider run. Qualify those claims on the exact maintained build through the selected native acceptance owner.
## Versioned driver boundary
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, selected task completion 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. Cross-host migration, RAM snapshots and arbitrary native-store portability remain outside this reference.
## Lifecycle and restore limits
The placement name is supplied by the caller. Create refuses an already-listed name, and each binding retains its one selected name; OpenRig does not reserve names globally across bindings. Immediately before stop/delete, OpenRig checks the saved immutable identity, then the pinned vendor call addresses the name and workspace. A deliberate reuse of that name between inspection and the vendor call could affect the replacement. The pinned CLI has no expected-id condition for these calls. Later reconciliation cannot undo that mutation.
Exact restore onto recreated compute stages the saved role, skills, plugins and required startup files as destination files, including files originally intended only for a fresh start. It does not replay startup actions or text into the conversation. Checkpoints retain the destination source references and hashes; missing or changed sources refuse restore rather than substitute today's bytes. The original native history and verified artifact bytes remain separate and unchanged. The restored destination manifest is checked again before the native resume latch.
A pending finite-completion attempt records its last outcome and attempt time in the existing operation receipt. Queue events and boot/manual checks observe it at most once per 60 seconds; there is no new polling timer. Eligibility is checked again after the driver's identity await, immediately before its stop command. A known refusal before dispatch releases only the unstarted effect claim; an uncertain dispatched request is never replayed. With no pending selection, a queue event performs one filtered query and no provider call; the operation table has no additional index or migration in this increment.
Checkpoints contain full private native history and currently have no removal or expiry command. Retain them under the daemon owner's private state root and account for their disk use. Stop-native does not terminate every process the native started or revoke its activity grant; children can outlive it during the bounded supervisor period. Viewer close does not itself finalize the ordinary session row. The earlier per-resource startup fence still requires manual reconciliation of a saved launch, and a successor-generation mismatch after a failed pre-launch projection can still reach projection before later launch refusal. These are named limits, not completion or activity claims.
A lifecycle driver must run its optional final dispatch check after its last asynchronous identity observation and immediately before issuing a stop command. A declined check means no command was sent; transport failure after dispatch remains uncertain. Core also checks at its call boundary.
Lifecycle requester audit uses the existing `X-OpenRig-Session` transport attribution and overrides a body `requestedBy` claim. It is not credential-backed seat authentication; any process able to reach the trusted daemon can supply that header. The separately scoped work and activity grants authenticate their own ingress only.
@@ -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
}
+303
View File
@@ -0,0 +1,303 @@
#!/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 subprocess
import signal
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"):
if value.get("nativeType") != "community:openshell-claude" or value["resumeToken"] != value["nativeId"]:
raise Refused("environment_native_identity_invalid")
checkpoint = value.get("checkpoint", {})
files = checkpoint.get("files", []) if isinstance(checkpoint, dict) else []
if not isinstance(files, list) or not 1 <= len(files) <= 129:
raise Refused("environment_checkpoint_invalid")
history = [file for file in files if file.get("path", "").startswith("native/projects/")]
if len(history) != 1 or not history[0]["path"].endswith("/" + value["nativeId"] + ".jsonl"):
raise Refused("environment_checkpoint_identity_mismatch")
if checkpoint.get("nativeId") != value["nativeId"] or checkpoint.get("nativeType") != value["nativeType"]:
raise Refused("environment_checkpoint_identity_mismatch")
for file in files:
data = regular(root, file["path"])
if digest(data) != file["sha256"] or len(data) != file["bytes"]:
raise Refused("environment_checkpoint_changed")
if not file["path"].startswith("native/projects/"):
continue
rows = [json.loads(line) for line in data.decode().splitlines()]
if not any(row.get("sessionId") == value["nativeId"] and row.get("type") in ("user", "assistant") for row in rows):
raise Refused("environment_checkpoint_identity_mismatch")
if any(row.get("sessionId", value["nativeId"]) != value["nativeId"] for row in rows):
raise Refused("environment_checkpoint_identity_mismatch")
if not checkpoint.get("files"):
raise Refused("environment_checkpoint_missing")
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", "--resume" if value.get("resumeToken") else "--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)
child = subprocess.Popen(argv, env=env, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
started = process_start(child.pid)
atomic(root, "control/native.json", json.dumps({"nativeId": value["nativeId"], "generation": value["generation"], "pid": child.pid, "started": started}).encode())
code = child.wait()
atomic(root, "control/native-exit.json", json.dumps({"nativeId": value["nativeId"], "generation": value["generation"], "pid": child.pid, "exitCode": code}).encode())
# Keep bounded file/control access after native exit, so checkpoint/export do not depend on a live model.
deadline = time.monotonic() + 1800
while time.monotonic() < deadline:
time.sleep(1)
def process_start(pid):
raw = (Path("/proc") / str(pid) / "stat").read_text()
return raw[raw.rfind(")") + 2:].split()[19]
def native_identity(root):
value = json.loads(regular(root, "control/native.json"))
try:
if process_start(value["pid"]) != value["started"]:
raise Refused("environment_native_changed")
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_changed")
return {"ok": True, **value, "running": True}
except FileNotFoundError:
exit_record = json.loads(regular(root, "control/native-exit.json"))
if any(exit_record.get(key) != value.get(key) for key in ("nativeId", "generation", "pid")):
raise Refused("environment_native_unconfirmed")
return {"ok": True, **value, "running": False, "exitCode": exit_record["exitCode"]}
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")
p.add_argument("--stop-native")
p.add_argument("--generation")
p.add_argument("--checkpoint-files", 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:
return native_identity(root)
if args.stop_native:
value = native_identity(root)
if value["nativeId"] != args.stop_native or value["generation"] != args.generation:
raise Refused("environment_native_changed")
if value["running"]:
os.kill(value["pid"], signal.SIGTERM)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
try:
if not native_identity(root)["running"]:
break
except (Refused, OSError):
pass
time.sleep(.1)
return {"ok": True, "signal": "TERM", "nativeId": value["nativeId"], "generation": value["generation"]}
if args.checkpoint_files:
value = native_identity(root)
relative = "native/projects/-sandbox-openrig-work/" + value["nativeId"] + ".jsonl"
data = regular(root, relative)
# A running writer's trailing partial record is not a checkpoint; retry after it settles.
if not data.endswith(b"\n"):
raise Refused("environment_checkpoint_incomplete")
return {"ok": True, "files": [{"path": relative, "bytes": len(data), "sha256": digest(data)}]}
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,12 @@
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 restore requires a verified private checkpoint and explicitly recreated same-host compute; missing state never starts fresh.
launch_result: true
resume: true
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; resumeType?: 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,117 @@
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 { OpenShellPodmanObserver, type PodmanCleanupSelection } from "./openshell-podman-observer.js";
import type { NativeObservation } from "../domain/environment-lifecycle.js";
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, ready = true): 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 (ready && actual.phase !== "Ready") throw new EnvironmentError("environment_not_ready", "The bound OpenShell resource is not Ready");
}
async guest(binding: EnvironmentBinding, args: string[], beforeDispatch?: () => boolean): Promise<Record<string, any>> {
await this.checked(binding);
if (beforeDispatch && !beforeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected stop is no longer eligible; no command was dispatched");
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 });
}
async captureCleanup(binding: EnvironmentBinding, socket: string): Promise<PodmanCleanupSelection> {
await this.checked(binding, false);
return new OpenShellPodmanObserver().capture(binding, socket);
}
async cleanupState(binding: EnvironmentBinding, selection?: PodmanCleanupSelection) {
return new OpenShellPodmanObserver().observe(binding, selection);
}
async nativeIdentity(binding: EnvironmentBinding): Promise<NativeObservation> {
const observed = await this.guest(binding, ["--identity"]);
if (typeof observed.running !== "boolean" || typeof observed.nativeId !== "string" || typeof observed.generation !== "string")
throw new EnvironmentError("environment_native_unconfirmed", "The exact native execution is not observed");
return observed as NativeObservation;
}
async stopNative(binding: EnvironmentBinding, native: NativeObservation, _operationId: string, beforeDispatch?: () => boolean): Promise<void> {
await this.guest(binding, ["--stop-native", native.nativeId, "--generation", native.generation], beforeDispatch);
}
async checkpointFiles(binding: EnvironmentBinding, _native: NativeObservation): Promise<Array<{ path: string; sha256: string; bytes: number }>> {
const result = await this.guest(binding, ["--checkpoint-files"]);
if (!Array.isArray(result.files) || result.files.length !== 1) throw new EnvironmentError("environment_checkpoint_invalid", "The guest did not return one exact history contract");
return result.files;
}
async stopCompute(binding: EnvironmentBinding, _operationId: string, beforeDispatch?: () => boolean): Promise<void> {
await this.checked(binding, false);
if (beforeDispatch && !beforeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected stop is no longer eligible; no command was dispatched");
await this.command([...this.prefix(binding.placement), "stop", binding.placement.name]);
}
async deleteEnvironment(binding: EnvironmentBinding, _operationId: string): Promise<void> {
const actual = await this.inspect(binding);
if (!binding.resource || actual.id !== binding.resource.id || actual.name !== binding.resource.name || actual.workspace !== binding.resource.workspace
|| binding.resource.fingerprint && actual.fingerprint !== binding.resource.fingerprint)
throw new EnvironmentError("environment_identity_changed", "The selected compute changed before deletion");
await this.command([...this.prefix(binding.placement), "delete", binding.placement.name]);
}
connectArgs(binding: EnvironmentBinding): string[] { return [...this.prefix(binding.placement), "connect", binding.placement.name]; }
}
@@ -0,0 +1,72 @@
import { request } from "node:http";
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute } from "node:path";
import { hostname } from "node:os";
import { EnvironmentError, type EnvironmentBinding } from "../domain/environment-platform.js";
import type { DependentObservation } from "../domain/environment-lifecycle.js";
export type PodmanCleanupSelection = { version: 1; socket: string; socketDevice: number; socketInode: number; uid: number; host: string;
resourceId: string; workspace: string; workloadId: string; supervisorId: string; volumes: string[] };
type EngineRead = (socket: string, path: string) => Promise<{ status: number; body: unknown }>;
const read: EngineRead = (socket, path) => new Promise((resolve, reject) => {
const req = request({ socketPath: socket, path, method: "GET", timeout: 5000 }, res => {
const chunks: Buffer[] = []; let bytes = 0;
res.on("data", chunk => { bytes += chunk.length; if (bytes > 2 * 1024 * 1024) { req.destroy(new Error("Engine inventory exceeds its bound")); return; } chunks.push(chunk); });
res.on("error", reject); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null }); } catch (error) { reject(error); } });
});
req.on("timeout", () => req.destroy(new Error("Engine inventory timed out"))); req.on("error", reject); req.end();
});
/** Read-only local rootless engine inventory. No engine mutation or global-emptiness requirement. */
export class OpenShellPodmanObserver {
constructor(private readonly get: EngineRead = read) {}
private socket(path: string): { socket: string; socketDevice: number; socketInode: number; uid: number; host: string } {
if (process.platform !== "linux" || !isAbsolute(path) || process.getuid?.() === 0) throw new EnvironmentError("environment_engine_unsupported", "Select a local non-root Podman socket on the provider host");
if (realpathSync(path) !== path) throw new EnvironmentError("environment_engine_changed", "The selected engine socket must not be a symlink");
const stat = lstatSync(path), uid = process.getuid!();
if (!stat.isSocket() || stat.uid !== uid) throw new EnvironmentError("environment_engine_changed", "The engine socket must belong to this rootless provider user");
return { socket: path, socketDevice: stat.dev, socketInode: stat.ino, uid, host: hostname() };
}
private async rows(socket: string): Promise<Array<Record<string, any>>> {
const result = await this.get(socket, "/libpod/containers/json?all=true");
if (result.status !== 200 || !Array.isArray(result.body)) throw new EnvironmentError("environment_cleanup_unconfirmed", "Complete local engine inventory is unavailable");
return result.body;
}
async capture(binding: EnvironmentBinding, path: string): Promise<PodmanCleanupSelection> {
const selected = this.socket(path), id = binding.resource!.id, rows = await this.rows(path);
const owned = rows.filter(row => row.Labels?.["openshell.ai/sandbox-id"] === id && row.Labels?.["openshell.managed"] === "true");
const workload = owned.filter(row => row.Labels?.["openshell.ai/isolation-role"] === "sandbox");
const supervisor = rows.filter(row => Array.isArray(row.Names) && row.Names.some((name: string) => name.replace(/^\//, "") === `openshell-supervisor-${id}`));
const workloadContainer = workload[0], supervisorContainer = supervisor[0];
if (workload.length !== 1 || supervisor.length !== 1 || !workloadContainer || !supervisorContainer || workloadContainer.Labels?.["openshell.ai/sandbox-workspace"] !== binding.placement.workspace)
throw new EnvironmentError("environment_cleanup_unconfirmed", "The exact local workload and supervisor must be observed before deletion");
const volumes = [`openshell-sandbox-${id}-workspace`, `openshell-control-${id}`, `openshell-channel-${id}`];
const containers = [workloadContainer, supervisorContainer];
const mounted = new Set<string>();
for (const container of containers) {
if (typeof container.Id !== "string" || !/^[0-9a-f]{64}$/.test(container.Id)) throw new EnvironmentError("environment_cleanup_unconfirmed", "Engine container identity is malformed");
const inspected = await this.get(path, `/libpod/containers/${container.Id}/json`);
const actual = inspected.body as Record<string, any>;
if (inspected.status !== 200 || actual?.Id !== container.Id || !Array.isArray(actual.Mounts)) throw new EnvironmentError("environment_cleanup_unconfirmed", "The captured container cannot be inspected");
for (const mount of actual.Mounts) if (mount.Type === "volume" && typeof mount.Name === "string") mounted.add(mount.Name);
}
if (volumes.some(volume => !mounted.has(volume))) throw new EnvironmentError("environment_cleanup_unconfirmed", "Expected workspace/control/channel volumes are not attributable to the selected containers");
return { version: 1, ...selected, resourceId: id, workspace: binding.placement.workspace, workloadId: workloadContainer.Id, supervisorId: supervisorContainer.Id, volumes };
}
async observe(binding: EnvironmentBinding, selected?: PodmanCleanupSelection): Promise<DependentObservation> {
if (!selected || selected.version !== 1 || selected.resourceId !== binding.resource!.id || selected.workspace !== binding.placement.workspace)
throw new EnvironmentError("environment_cleanup_unconfirmed", "No pre-delete exact local engine inventory is retained");
const now = this.socket(selected.socket);
if (now.host !== selected.host || now.uid !== selected.uid || now.socketDevice !== selected.socketDevice || now.socketInode !== selected.socketInode)
throw new EnvironmentError("environment_cleanup_unconfirmed", "The selected local engine endpoint changed; absence is not established");
const rows = await this.rows(selected.socket);
const workload = rows.some(row => row.Id === selected.workloadId || row.Labels?.["openshell.ai/sandbox-id"] === selected.resourceId && row.Labels?.["openshell.ai/isolation-role"] === "sandbox");
const supervisor = rows.some(row => row.Id === selected.supervisorId || Array.isArray(row.Names) && row.Names.some((name: string) => name.replace(/^\//, "") === `openshell-supervisor-${selected.resourceId}`));
let volumes = false;
for (const name of selected.volumes) {
const observed = await this.get(selected.socket, `/libpod/volumes/${encodeURIComponent(name)}/json`);
if (observed.status === 200) volumes = true;
else if (observed.status !== 404) throw new EnvironmentError("environment_cleanup_unconfirmed", "Volume lookup failed; it is not absence");
}
return { workload, supervisor, volumes };
}
}
+3 -1
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";
@@ -10,7 +12,6 @@ import { nodeClaudePermissionsSchema } from "./migrations/139_node_claude_permis
import { identityPrincipalSchema } from "./migrations/140_identity_principal.js";
import { sessionAgentRunnerSchema } from "./migrations/141_session_agent_runner.js";
import { nodeClaudeMcpSchema } from "./migrations/142_node_claude_mcp.js";
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { nodeCodexPermissionsSchema } from "./migrations/137_node_codex_permissions.js";
import { coreSchema } from "./migrations/001_core_schema.js";
import { terminalPolicySchema } from "./migrations/128_terminal_policy.js";
@@ -289,4 +290,5 @@ export const ALL_MIGRATIONS: Migration[] = [
nodeAccountHomeSchema,
nodeClaudeMcpSchema,
activityGrantsSchema,
environmentBindingsSchema,
];
@@ -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'))
);
`,
};
@@ -4,7 +4,8 @@
// conversation id for each launch (see `community-harness-launch-result.ts`); OpenRig records it as reported, not observed.
// With `names_conversation: true`, OpenRig names the conversation itself and passes the name to the command; it records it
// as named by OpenRig, not observed. OpenRig hasn't tested these harnesses: it shows them as community harnesses,
// never confirms their identity, and reads their activity only from what the screen shows. Built-in runtime names are
// labels launcher identity separately from observation. A declared managed runner can report activity through hooks;
// other presets use the screen. Built-in runtime names are
// reserved, so a preset can't replace one.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { basename, join } from "node:path";
@@ -191,13 +192,13 @@ export interface CommunityHarnessLabel {
reportedConversation?: string;
/** The conversation name OpenRig passed to the harness for this launch. */
namedConversation?: string;
needsInput: "from the screen only: no harness prompt pattern";
needsInput: "from the screen only: no harness prompt pattern" | "from structured activity and screen observations";
/** Set when the preset is missing or can't be used. */
presetError?: string;
}
export function communityHarnessLabel(runtime: string, dir: string = communityHarnessesDir(),
session?: { resumeType: string | null; resumeToken: string | null; resumeProvenance: string | null }): CommunityHarnessLabel {
session?: { resumeType: string | null; resumeToken: string | null; resumeProvenance: string | null; runner?: boolean }): CommunityHarnessLabel {
// A conversation is labelled reported only when the launcher reported it, and named only when OpenRig named it and passed
// it: a resume that needed attention keeps the conversation it attempted, with neither provenance.
const own = session?.resumeType === `community:${runtime}` && session.resumeToken ? session.resumeToken : undefined;
@@ -211,7 +212,7 @@ export function communityHarnessLabel(runtime: string, dir: string = communityHa
identity: reported ? "reported by its launcher, not observed" : named ? "named by OpenRig, not observed" : "not confirmed",
...(reported ? { reportedConversation: reported } : {}),
...(named ? { namedConversation: named } : {}),
needsInput: "from the screen only: no harness prompt pattern",
needsInput: session?.runner === true ? "from structured activity and screen observations" : "from the screen only: no harness prompt pattern",
...(found.ok ? {} : { presetError: found.message }),
};
}
@@ -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,332 @@
import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join, posix, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { EnvironmentPlatform, EnvironmentError, identifier, regularBytes, relativeGuestPath, sha256, type EnvironmentBinding, type EnvironmentDriver, type Placement, type ProjectionReceipt } from "./environment-platform.js";
import type { PodmanCleanupSelection } from "../adapters/openshell-podman-observer.js";
import type { QueueRepository } from "./queue-repository.js";
export type EnvironmentEffect = "close-viewer" | "stop-native" | "stop-compute" | "delete-owned";
export type NativeObservation = { nativeId: string; generation: string; running: boolean };
export type DependentObservation = { workload: boolean; supervisor: boolean; volumes: boolean };
export type EnvironmentLifecycleDriver = EnvironmentDriver & {
nativeIdentity?(binding: EnvironmentBinding): Promise<NativeObservation>;
stopNative?(binding: EnvironmentBinding, native: NativeObservation, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
stopCompute?(binding: EnvironmentBinding, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
deleteEnvironment?(binding: EnvironmentBinding, operationId: string): Promise<void>;
captureCleanup?(binding: EnvironmentBinding, socket: string): Promise<PodmanCleanupSelection>;
cleanupState?(binding: EnvironmentBinding, selection?: PodmanCleanupSelection): Promise<DependentObservation>;
checkpointFiles?(binding: EnvironmentBinding, native: NativeObservation): Promise<Array<{ path: string; sha256: string; bytes: number }>>;
};
export type EnvironmentCheckpoint = {
version: 1; checkpointId: string; bindingId: string; nodeId: string; seat: string; generation: string;
nativeType: "community:openshell-claude"; nativeId: string; predecessorResourceId: string;
context?: ProjectionReceipt[];
destinationCwd: string; nativeState: string; artifacts: Array<{ path: string; source: string; sha256: string; bytes: number; taskId: string }>; files: Array<{ path: string; source: string; sha256: string; bytes: number }>;
};
export type EffectReceipt = { version: 1; operationId: string; bindingId: string; generation: string; resourceId: string;
effect: EnvironmentEffect; state: "confirmed" | "pending" | "unconfirmed"; remaining?: DependentObservation; detail?: string };
type Operation = { binding_id: string; kind: string; request_json: string; state: string; receipt_json: string | null; effect_id: 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); }
function privateDirectory(path: string): void {
let current = resolve(path);
while (current !== resolve(current, "..")) {
try { if (lstatSync(current).isSymbolicLink()) refuse("environment_state_symlink", "Private environment state has a symlink component"); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
current = resolve(current, "..");
}
mkdirSync(path, { recursive: true, mode: 0o700 });
}
function syncFile(path: string): void { const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); } }
/** Existing binding/effect journal, with no task duplication or provider-wide policy engine. */
export class EnvironmentLifecycle {
private readonly driver: EnvironmentLifecycleDriver;
constructor(readonly platform: EnvironmentPlatform, private readonly queue: QueueRepository | undefined, private readonly stateRoot: string,
private readonly viewer?: (binding: EnvironmentBinding) => Promise<{ confirmed: boolean }>) { this.driver = platform.driver; }
private selected(id: string, generation: string): EnvironmentBinding {
identifier(generation, "generation"); const binding = this.platform.get(id);
if (!binding?.resource || !binding.nodeId) refuse("environment_binding_missing", "A confirmed node and compute binding is required");
if (binding.generation !== generation) refuse("environment_generation_changed", "The selected execution generation changed");
return binding;
}
private operation(id: string): Operation | undefined { return this.platform.db.prepare("SELECT * FROM environment_operations WHERE operation_id = ?").get(id) as Operation | undefined; }
private intent(binding: EnvironmentBinding, id: string, kind: string, request: unknown): Operation {
identifier(id, "operationId");
return this.platform.db.transaction(() => {
const existing = this.operation(id), text = stable(request);
if (existing) {
if (existing.binding_id !== binding.bindingId || existing.kind !== kind || existing.request_json !== text) refuse("environment_operation_changed", "The saved effect identity has different input");
return existing;
}
this.platform.db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,effect_id,state) VALUES (?,?,?,?,?,'intent')")
.run(id, binding.bindingId, kind, text, `env-${randomUUID()}`);
return this.operation(id)!;
})();
}
private save(id: string, receipt: unknown): void {
this.platform.db.prepare("UPDATE environment_operations SET state='confirmed',receipt_json=? WHERE operation_id=?").run(JSON.stringify(receipt), id);
}
private claim(id: string): boolean { return this.platform.db.prepare("UPDATE environment_operations SET state='requested' WHERE operation_id=? AND state='intent'").run(id).changes === 1; }
native(binding: EnvironmentBinding): { nativeId: string; generation: string; nativeType: "community:openshell-claude" } {
const rows = this.platform.db.prepare("SELECT receipt_json FROM environment_operations WHERE binding_id=? AND kind='launch' AND state='confirmed' ORDER BY rowid DESC").all(binding.bindingId) as Array<{ receipt_json: string }>;
const native = rows.map(row => JSON.parse(row.receipt_json)).find(row => row.resourceId === binding.resource!.id && row.generation === binding.generation);
if (!native || native.nativeType !== "community:openshell-claude" || !/^[0-9a-f]{8}-[0-9a-f-]{27}$/.test(native.nativeId)) refuse("environment_native_unconfirmed", "The selected compute has no confirmed exact native identity");
return native;
}
async effect(id: string, input: { operationId: string; generation: string; effect: EnvironmentEffect; engineSocket?: string; requestedBy?: string }, beforeDispatch?: () => boolean): Promise<EffectReceipt> {
if (!["close-viewer", "stop-native", "stop-compute", "delete-owned"].includes(input.effect)) refuse("environment_effect_unsupported", "Select close-viewer, stop-native, stop-compute or delete-owned", 400);
const binding = this.selected(id, input.generation);
if (input.effect !== "close-viewer" && binding.ownership !== "owned") refuse("environment_attached_preserved", "Attached native, storage and compute are preserved; close only this consumer's viewer");
const request = { ...input, resourceId: binding.resource!.id }, op = this.intent(binding, input.operationId, "effect", request);
if (op.receipt_json) return JSON.parse(op.receipt_json);
const base = { version: 1 as const, operationId: input.operationId, bindingId: id, generation: input.generation, resourceId: binding.resource!.id, effect: input.effect, ...(input.requestedBy ? { requestedBy: input.requestedBy } : {}) };
const result = (state: EffectReceipt["state"], extra: Partial<EffectReceipt> = {}): EffectReceipt => ({ ...base, state, ...extra });
const confirm = (extra: Partial<EffectReceipt> = {}) => { const receipt = result("confirmed", extra); this.save(input.operationId, receipt); return receipt; };
// Re-check at Core's call boundary AND inside the driver's final identity await.
// Only a known pre-dispatch refusal releases the claim; transport uncertainty never does.
const dispatch = async (run: (allowed: () => boolean) => Promise<void>): Promise<boolean> => {
if (!this.claim(input.operationId)) return true;
let refused = false;
const allowed = () => {
const current = this.platform.get(id);
const eligible = current?.generation === input.generation && current.resource?.id === binding.resource!.id && (!beforeDispatch || beforeDispatch());
if (!eligible) refused = true;
return eligible;
};
try { if (!allowed()) throw new Error("selection changed"); await run(allowed); }
catch { if (refused) { this.platform.db.prepare("UPDATE environment_operations SET state='intent' WHERE operation_id=? AND state='requested'").run(input.operationId); return false; } }
return true;
};
try {
if (input.effect === "close-viewer") {
if (!this.viewer) refuse("environment_effect_unsupported", "This daemon has no managed viewer close operation", 501);
// Viewer cleanup is repeatably observational at the exact owned seat; it never reaches the guest provider.
return (await this.viewer(binding)).confirmed ? confirm() : result("unconfirmed", { detail: "Viewer absence is not confirmed; native and compute are unchanged" });
}
if (input.effect === "delete-owned") {
if (!this.driver.deleteEnvironment || !this.driver.cleanupState) refuse("environment_effect_unsupported", "This driver cannot verify workload/supervisor/volume cleanup", 501);
let listed = await this.driver.list(binding.placement);
if (!listed.complete) return result("unconfirmed", { detail: "Provider listing is incomplete" });
if (listed.resources.some(row => row.name === binding.resource!.name && row.id !== binding.resource!.id)) refuse("environment_identity_changed", "The resource name was reused; nothing was deleted");
const inventoryId = `inventory-${binding.resource!.id}`;
let inventory = this.operation(inventoryId)?.receipt_json;
if (this.driver.captureCleanup && !inventory) {
if (!input.engineSocket) refuse("environment_engine_required", "Select the local provider's rootless Podman socket before delete", 400);
const captured = await this.driver.captureCleanup(binding, input.engineSocket);
this.intent(binding, inventoryId, "cleanup-inventory", { resourceId: binding.resource!.id, engineSocket: input.engineSocket });
this.save(inventoryId, captured); inventory = JSON.stringify(captured);
}
const found = listed.resources.find(row => row.id === binding.resource!.id);
if (found) {
this.platform.assertResource(binding, found);
this.selected(id, input.generation);
if (this.claim(input.operationId)) { try { await this.driver.deleteEnvironment(binding, op.effect_id!); } catch { /* observe original effect, never repeat */ } }
listed = await this.driver.list(binding.placement);
}
if (!listed.complete || listed.resources.some(row => row.id === binding.resource!.id || row.name === binding.resource!.name)) return result("pending", { detail: "Exact provider absence is not confirmed" });
const remaining = await this.driver.cleanupState(binding, inventory ? JSON.parse(inventory) : undefined);
if (Object.values(remaining).some(value => value !== false)) return result("unconfirmed", { remaining, detail: "Provider absence does not establish dependent cleanup" });
return confirm({ remaining });
}
const actual = await this.platform.current(id);
if (input.effect === "stop-compute") {
if (!this.driver.stopCompute) refuse("environment_effect_unsupported", "Compute stop is unsupported", 501);
if (actual.resource!.phase === "Stopped") return confirm();
// current() checks identity but retains stored phase; observe the fresh phase explicitly.
if ((await this.driver.inspect(binding)).phase === "Stopped") return confirm();
if (!await dispatch(allowed => this.driver.stopCompute!(binding, op.effect_id!, allowed))) return result("pending", { detail: "The selection changed before dispatch; no stop was sent" });
const after = await this.driver.inspect(binding); this.platform.assertResource(binding, after);
return after.phase === "Stopped" ? confirm() : result("pending", { detail: "Compute stop was requested; Stopped is not yet observed" });
}
if (!this.driver.nativeIdentity || !this.driver.stopNative) refuse("environment_effect_unsupported", "Exact native stop is unsupported", 501);
const native = this.native(binding), before = await this.driver.nativeIdentity(binding);
if (before.nativeId !== native.nativeId || before.generation !== input.generation) refuse("environment_native_changed", "The observed native execution differs; no signal was sent");
if (!before.running) return confirm();
if (!await dispatch(allowed => this.driver.stopNative!(binding, before, op.effect_id!, allowed))) return result("pending", { detail: "The selection changed before dispatch; no stop was sent" });
const after = await this.driver.nativeIdentity(binding);
if (after.nativeId !== native.nativeId || after.generation !== input.generation) return result("unconfirmed", { detail: "The native observation changed during stop" });
return !after.running ? confirm() : result("pending", { detail: "Native TERM requested; execution is still present" });
} catch (error) {
if (error instanceof EnvironmentError) throw error;
return result("unconfirmed", { detail: "The original effect could not be observed; its intent remains, and it was not retried" });
}
}
checkpointRecord(id: string): EnvironmentCheckpoint | null {
const row = this.operation(id); return row?.kind === "checkpoint" && row.receipt_json ? JSON.parse(row.receipt_json) : null;
}
verifyCheckpoint(checkpoint: EnvironmentCheckpoint): void {
if ((checkpoint.context?.length ?? 0) > 4096 || (checkpoint.context ?? []).reduce((bytes, file) => bytes + file.bytes, 0) > 32 * 1024 * 1024) refuse("environment_checkpoint_invalid", "Retained destination context exceeds its file cap");
for (const file of checkpoint.context ?? []) {
relativeGuestPath(file.path);
if (!file.sourceFile || !file.sourceSha256 || sha256(regularBytes(file.sourceFile)) !== file.sourceSha256)
refuse("environment_checkpoint_changed", "A retained context source is missing or changed; no substitute was staged");
}
if ((checkpoint.artifacts?.length ?? 0) > 128 || (checkpoint.artifacts ?? []).reduce((sum, file) => sum + file.bytes, 0) > 32 * 1024 * 1024)
refuse("environment_checkpoint_invalid", "Retained artifacts exceed 128 files or 32 MiB");
for (const artifact of checkpoint.artifacts ?? []) {
relativeGuestPath(artifact.path);
if (!artifact.path.startsWith("work/artifacts/")) refuse("environment_checkpoint_invalid", "A retained artifact must stay beneath the work artifact root");
const data = regularBytes(artifact.source);
if (data.length !== artifact.bytes || sha256(data) !== artifact.sha256) refuse("environment_checkpoint_changed", "A retained required artifact changed before restore");
}
if (checkpoint.nativeType !== "community:openshell-claude" || !Array.isArray(checkpoint.files) || checkpoint.files.length < 1 || checkpoint.files.length > 128) refuse("environment_checkpoint_invalid", "A complete native checkpoint is required");
let bytes = 0;
for (const file of checkpoint.files) {
const path = relativeGuestPath(file.path);
if (!path.startsWith("native/projects/") || !path.endsWith(`/${checkpoint.nativeId}.jsonl`)) refuse("environment_checkpoint_invalid", "The checkpoint does not select this exact native history");
const data = regularBytes(file.source, 1024 * 1024); bytes += data.length;
if (data.length !== file.bytes || sha256(data) !== file.sha256 || bytes > 1024 * 1024) refuse("environment_checkpoint_changed", "Retained native bytes changed or are missing; no fresh conversation was started");
const lines = data.toString("utf8").trimEnd().split("\n");
if (!lines.length || !lines.some(line => { try { const row = JSON.parse(line); return row.sessionId === checkpoint.nativeId && ["user", "assistant"].includes(row.type); } catch { return false; } })) refuse("environment_checkpoint_identity_mismatch", "The retained native history does not identify the selected conversation");
for (const line of lines) { let row: Record<string, unknown>; try { row = JSON.parse(line); } catch { refuse("environment_checkpoint_invalid", "History must end at a complete JSONL record"); }
if (row.sessionId !== undefined && row.sessionId !== checkpoint.nativeId) refuse("environment_checkpoint_identity_mismatch", "Another conversation appeared in the selected history"); }
}
}
async checkpoint(id: string, input: { operationId: string; generation: string }): Promise<EnvironmentCheckpoint> {
const binding = this.selected(id, input.generation), native = this.native(binding);
const op = this.intent(binding, input.operationId, "checkpoint", { ...input, resourceId: binding.resource!.id, nativeId: native.nativeId });
if (op.receipt_json) { const saved = JSON.parse(op.receipt_json); this.verifyCheckpoint(saved); return saved; }
if (!this.driver.checkpointFiles || !this.driver.nativeIdentity) refuse("environment_checkpoint_unsupported", "This driver cannot retain exact native history", 501);
await this.platform.current(id, true);
const observed = await this.driver.nativeIdentity(binding);
if (observed.nativeId !== native.nativeId || observed.generation !== input.generation) refuse("environment_native_changed", "The checkpoint source native identity changed");
const selectionId = `selection-${sha256(input.operationId).slice(0, 48)}`;
let selection = this.operation(selectionId)?.receipt_json;
if (!selection) {
const files = await this.driver.checkpointFiles(binding, observed);
if (files.length !== 1) refuse("environment_checkpoint_invalid", "One exact native history file is required");
const exports = this.platform.db.prepare("SELECT receipt_json FROM environment_operations WHERE binding_id=? AND kind='export' AND state='confirmed' AND json_extract(receipt_json,'$.generation')=? LIMIT 129").all(id, input.generation) as Array<{ receipt_json: string }>;
if (exports.length > 128) refuse("environment_checkpoint_invalid", "Retained artifacts exceed 128 files");
const artifacts: EnvironmentCheckpoint["artifacts"] = [];
for (const row of exports) {
const receipt = JSON.parse(row.receipt_json);
if (receipt.generation !== input.generation) continue;
const path = posix.relative(binding.guestRoot, receipt.guestPath), data = regularBytes(receipt.path);
if (!path.startsWith("work/artifacts/") || data.length !== receipt.bytes || sha256(data) !== receipt.sha256) refuse("environment_checkpoint_changed", "A required verified export changed before checkpoint");
artifacts.push({ path, source: receipt.path, sha256: receipt.sha256, bytes: receipt.bytes, taskId: receipt.taskId });
}
selection = this.platform.db.transaction(() => {
const op = this.intent(binding, selectionId, "checkpoint-selection", { checkpointId: input.operationId, generation: input.generation, resourceId: binding.resource!.id, nativeId: native.nativeId });
if (op.receipt_json) return op.receipt_json;
const projection = this.platform.db.prepare("SELECT projection_json FROM environment_bindings WHERE binding_id=?").get(id) as { projection_json: string | null };
const context = (projection.projection_json ? JSON.parse(projection.projection_json).files : []).filter((file: ProjectionReceipt) => file.sourceFile);
const selected = { files, artifacts, context }; this.save(selectionId, selected); return JSON.stringify(selected);
})();
}
const selected = JSON.parse(selection) as { files: Array<{ path: string; sha256: string; bytes: number }>; artifacts: EnvironmentCheckpoint["artifacts"]; context?: ProjectionReceipt[] };
const directory = join(resolve(this.stateRoot), id, input.operationId); privateDirectory(directory);
const stage = mkdtempSync(join(directory, ".capture-"));
try {
const files: EnvironmentCheckpoint["files"] = [];
for (const [index, file] of selected.files.entries()) {
relativeGuestPath(file.path);
if (!file.path.startsWith("native/projects/") || !file.path.endsWith(`/${native.nativeId}.jsonl`) || file.bytes < 1 || file.bytes > 1024 * 1024 || !/^[0-9a-f]{64}$/.test(file.sha256)) refuse("environment_checkpoint_invalid", "The selected native history contract is invalid");
// Driver read is a bounded regular-file byte operation, never a vendor archive extraction.
const data = (await this.driver.read(binding, posix.join(binding.guestRoot, file.path))).subarray(0, file.bytes);
if (data.length !== file.bytes || sha256(data) !== file.sha256) refuse("environment_checkpoint_changed", "History changed during capture; retain the original operation and capture a stable complete prefix");
const source = join(directory, `history-${index}.jsonl`), temp = join(stage, `history-${index}`);
writeFileSync(temp, data, { mode: 0o600 }); syncFile(temp);
try { linkSync(temp, source); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
files.push({ ...file, source });
}
const checkpoint: EnvironmentCheckpoint = { version: 1, checkpointId: input.operationId, bindingId: id, nodeId: binding.nodeId!, seat: binding.seat,
generation: input.generation, nativeType: native.nativeType, nativeId: native.nativeId, predecessorResourceId: binding.resource!.id,
destinationCwd: posix.join(binding.guestRoot, "work"), nativeState: posix.join(binding.guestRoot, "native"), artifacts: selected.artifacts, context: selected.context ?? [], files };
this.verifyCheckpoint(checkpoint); this.selected(id, input.generation); syncFile(directory); this.save(input.operationId, checkpoint); return checkpoint;
} finally { rmSync(stage, { recursive: true }); }
}
async recreate(id: string, input: { operationId: string; generation: string; checkpointId: string; resumeType: string; resumeToken: string; placement: Placement }): Promise<Record<string, unknown>> {
identifier(input.generation, "generation"); const binding = this.platform.get(id);
if (!binding?.resource || binding.ownership !== "owned") refuse("environment_attached_preserved", "Only a creator may explicitly recreate its compute; attached state is preserved");
const cp = this.checkpointRecord(input.checkpointId);
if (!cp || cp.bindingId !== id || cp.nodeId !== binding.nodeId || cp.seat !== binding.seat || input.resumeType !== cp.nativeType || input.resumeToken !== cp.nativeId) refuse("environment_checkpoint_missing", "Select this binding's exact retained checkpoint and native type/id; no fresh fallback exists");
this.verifyCheckpoint(cp);
if (input.generation !== cp.generation) refuse("environment_generation_changed", "Recreation must select the predecessor checkpoint generation");
if (!input.placement || input.placement.gateway !== binding.placement.gateway || input.placement.workspace !== binding.placement.workspace
|| input.placement.image !== binding.placement.image || typeof input.placement.provider !== "string" || !input.placement.provider || /[\s\0]/.test(input.placement.provider) || !/^[a-z0-9](?:[a-z0-9-]{0,17}[a-z0-9])?$/.test(input.placement.name)) refuse("environment_recreate_changed", "Recreation must use the same qualified host/workspace/image and an explicit provider reference and a selected name");
const policy = regularBytes(input.placement.policy);
const request = { ...input, placement: { ...input.placement, policySha256: sha256(policy) }, predecessorResourceId: cp.predecessorResourceId };
const op = this.intent(binding, input.operationId, "recreate", request);
if (op.receipt_json) return JSON.parse(op.receipt_json);
const listing = await this.driver.list(binding.placement);
if (!listing.complete) refuse("environment_listing_incomplete", "A complete provider listing is required before successor compute");
if (listing.resources.some(row => row.id === cp.predecessorResourceId)) refuse("environment_predecessor_present", "Stop and delete the predecessor explicitly before creating successor compute");
if (!this.driver.cleanupState || Object.values(await this.driver.cleanupState({ ...binding, resource: { ...binding.resource, id: cp.predecessorResourceId } }, (() => { const saved = this.operation(`inventory-${cp.predecessorResourceId}`)?.receipt_json; return saved ? JSON.parse(saved) : undefined; })())).some(value => value !== false)) refuse("environment_cleanup_unconfirmed", "Predecessor workload/supervisor/volume absence is unconfirmed");
const matches = listing.resources.filter(row => row.operationId === op.effect_id);
if (matches.length > 1) refuse("environment_duplicate_resources", "Multiple successor resources match this operation");
let resource = matches[0];
if (!resource) {
if (listing.resources.some(row => row.name === input.placement.name)) refuse("environment_name_in_use", "The successor name is already in use");
if (!this.claim(input.operationId)) return { state: "unconfirmed", operationId: input.operationId, checkpointId: cp.checkpointId };
try { resource = await this.driver.create({ operationId: op.effect_id!, placement: request.placement }); }
catch { return { state: "unconfirmed", operationId: input.operationId, checkpointId: cp.checkpointId }; }
}
if (!resource.id || resource.id === cp.predecessorResourceId || resource.name !== input.placement.name || resource.workspace !== binding.placement.workspace || resource.operationId !== op.effect_id) refuse("environment_identity_changed", "The successor resource does not match the frozen recreation");
if (this.driver.captureCleanup) {
const predecessor = this.operation(`inventory-${cp.predecessorResourceId}`)?.receipt_json;
if (!predecessor) refuse("environment_cleanup_unconfirmed", "The predecessor's actual provider host is not retained");
const previous = JSON.parse(predecessor) as PodmanCleanupSelection;
const captured = await this.driver.captureCleanup({ ...binding, resource, placement: request.placement }, previous.socket);
if (captured.host !== previous.host || captured.uid !== previous.uid || captured.socketDevice !== previous.socketDevice || captured.socketInode !== previous.socketInode)
refuse("environment_restore_host_changed", "The successor is not on the selected predecessor host/engine");
const inventoryId = `inventory-${resource.id}`;
this.intent(binding, inventoryId, "cleanup-inventory", { resourceId: resource.id, engineSocket: previous.socket }); this.save(inventoryId, captured);
}
const receipt = { version: 1, state: "confirmed", operationId: input.operationId, bindingId: id, checkpointId: cp.checkpointId,
nativeType: cp.nativeType, nativeId: cp.nativeId, predecessorResourceId: cp.predecessorResourceId, successorResourceId: resource.id, predecessorGeneration: input.generation, successorGeneration: null,
destinationCwd: cp.destinationCwd, nativeState: cp.nativeState };
this.platform.db.transaction(() => {
const current = this.platform.get(id)!;
if (current.resource!.id !== cp.predecessorResourceId) refuse("environment_selection_changed", "The compute binding changed during recreation");
this.platform.db.prepare("UPDATE environment_bindings SET resource_json=?,config_json=?,projection_json=NULL,generation=NULL WHERE binding_id=?")
.run(JSON.stringify(resource), JSON.stringify({ ...current, placement: request.placement, resource: undefined, ownership: undefined, generation: undefined }), id);
this.save(input.operationId, receipt);
})();
return receipt;
}
selectCompletion(id: string, input: { operationId: string; generation: string; taskId: string; exportOperationId: string; effect: "stop-native" | "stop-compute"; requestedBy?: string }): Record<string, unknown> {
const binding = this.selected(id, input.generation);
identifier(input.taskId, "taskId"); identifier(input.exportOperationId, "exportOperationId");
if (binding.ownership !== "owned" || !["stop-native", "stop-compute"].includes(input.effect)) refuse("environment_completion_invalid", "Finite completion needs one owned execution and a selected native or compute stop");
const task = this.queue?.getById(input.taskId);
if (!task || task.destinationSession !== binding.seat) refuse("environment_task_changed", "Finite completion selects work owned by this exact seat");
const op = this.intent(binding, input.operationId, "completion", { ...input, resourceId: binding.resource!.id });
return { ...input, state: op.state === "confirmed" ? "confirmed" : "pending", ...(op.receipt_json ? { lastAttempt: JSON.parse(op.receipt_json) } : {}) };
}
private completionEligible(id: string, input: { taskId: string; generation: string; resourceId: string; exportOperationId: string }): boolean {
const task = this.queue?.getById(input.taskId), binding = this.platform.get(id);
if (!task || task.state !== "done" || task.closureReason !== "no-follow-on" || task.destinationSession !== binding?.seat || binding.generation !== input.generation || binding.resource?.id !== input.resourceId) return false;
const exported = this.operation(input.exportOperationId);
if (!exported || exported.binding_id !== id || exported.kind !== "export" || exported.state !== "confirmed" || !exported.receipt_json) return false;
try {
const receipt = JSON.parse(exported.receipt_json);
if (receipt.bindingId !== id || receipt.generation !== input.generation || receipt.taskId !== input.taskId) return false;
const bytes = regularBytes(receipt.path);
return bytes.length === receipt.bytes && sha256(bytes) === receipt.sha256;
} catch { return false; }
}
async checkCompletions(): Promise<void> {
const rows = this.platform.db.prepare("SELECT operation_id, binding_id, request_json, receipt_json FROM environment_operations WHERE kind='completion' AND state IN ('intent','requested')").all() as Array<{ operation_id: string; binding_id: string; request_json: string; receipt_json: string | null }>;
for (const row of rows) {
const input = JSON.parse(row.request_json), now = Date.now(), previous = row.receipt_json ? JSON.parse(row.receipt_json) : undefined;
// Queue traffic is not a provider polling loop. A manual/boot/event check observes at most once per minute.
if (previous?.lastAttemptAt !== undefined && now - previous.lastAttemptAt < 60_000) continue;
if (!this.completionEligible(row.binding_id, input)) continue;
const attempt = { state: "pending", lastAttemptAt: now, taskId: input.taskId, exportOperationId: input.exportOperationId, detail: "Checking the selected stop" };
const claimed = this.platform.db.prepare("UPDATE environment_operations SET state='requested',receipt_json=? WHERE operation_id=? AND state IN ('intent','requested') AND receipt_json IS ?")
.run(JSON.stringify(attempt), row.operation_id, row.receipt_json);
if (!claimed.changes) continue;
try {
const effect = await this.effect(row.binding_id, { operationId: `completion-stop-${sha256(row.operation_id).slice(0, 48)}`, generation: input.generation, effect: input.effect, requestedBy: input.requestedBy },
() => this.completionEligible(row.binding_id, input));
const receipt = { ...effect, lastAttemptAt: now, taskId: input.taskId, exportOperationId: input.exportOperationId };
if (effect.state === "confirmed") this.save(row.operation_id, receipt);
else this.platform.db.prepare("UPDATE environment_operations SET receipt_json=? WHERE operation_id=? AND state='requested'").run(JSON.stringify(receipt), row.operation_id);
} catch (error) {
this.platform.db.prepare("UPDATE environment_operations SET receipt_json=? WHERE operation_id=? AND state='requested'")
.run(JSON.stringify({ ...attempt, state: "unconfirmed", detail: "Selected completion remains unconfirmed; no unrelated work was selected", ...(error instanceof EnvironmentError ? { code: error.code } : {}) }), row.operation_id);
}
}
}
}
@@ -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,230 @@
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 { EnvironmentLifecycle, type EnvironmentCheckpoint } from "./environment-lifecycle.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; resumeType?: string; forkSource?: unknown } = {}): Promise<void> {
if (opts.forkSource) throw new EnvironmentError("environment_restore_unsupported", "Environment fork is unsupported; no guest input was replaced");
this.projectionFence(binding, false);
const checkpoint = this.retainedResume(binding, opts.resumeToken ? opts : undefined, false);
if (checkpoint && !opts.resumeToken) throw new EnvironmentError("environment_exact_resume_required", "Select the retained native type and id through normal restore; a fresh start is not a restore");
this.preparation(binding);
}
private preparation(binding: NodeBinding) {
const selected = this.selected(binding);
this.projectionFence(binding);
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 });
}
}
private retainedResume(binding: NodeBinding, resume?: { resumeToken?: string; resumeType?: string }, claimGeneration = true): EnvironmentCheckpoint | null {
const selected = this.selected(binding);
const row = this.platform.db.prepare("SELECT operation_id, receipt_json FROM environment_operations WHERE binding_id=? AND kind='recreate' AND state='confirmed' ORDER BY rowid DESC LIMIT 1").get(selected.bindingId) as { operation_id: string; receipt_json: string } | undefined;
if (!row) {
if (resume?.resumeToken || binding.environmentRestore) throw new EnvironmentError("environment_checkpoint_missing", "Explicit same-host compute recreation and its exact checkpoint are required before environment restore");
return null;
}
const recreated = JSON.parse(row.receipt_json);
if (recreated.successorResourceId !== selected.resource!.id || recreated.successorGeneration && recreated.successorGeneration !== binding.launchGeneration) {
if (resume?.resumeToken || binding.environmentRestore) throw new EnvironmentError("environment_restore_selection_changed", "The selected successor compute or generation changed");
return null;
}
const lifecycle = new EnvironmentLifecycle(this.platform, undefined, this.stageRoot), checkpoint = lifecycle.checkpointRecord(recreated.checkpointId);
if (!checkpoint || checkpoint.bindingId !== selected.bindingId || checkpoint.nodeId !== binding.nodeId || checkpoint.seat !== selected.seat
|| checkpoint.predecessorResourceId !== recreated.predecessorResourceId || recreated.nativeId !== checkpoint.nativeId)
throw new EnvironmentError("environment_checkpoint_identity_mismatch", "The saved predecessor/native checkpoint does not belong to this stable seat");
const snapshot = binding.environmentRestore;
if (snapshot && (snapshot.bindingId !== selected.bindingId || snapshot.checkpointId !== checkpoint.checkpointId || snapshot.nativeId !== checkpoint.nativeId || snapshot.nativeType !== checkpoint.nativeType
|| snapshot.resourceId !== checkpoint.predecessorResourceId || snapshot.destinationCwd !== checkpoint.destinationCwd || snapshot.nativeState !== checkpoint.nativeState))
throw new EnvironmentError("environment_checkpoint_identity_mismatch", "The snapshot selects another binding, compute or native checkpoint");
if (resume && (resume.resumeToken !== checkpoint.nativeId || resume.resumeType !== checkpoint.nativeType))
throw new EnvironmentError("environment_checkpoint_identity_mismatch", "Restore must request the exact retained native type and id; no fresh fallback exists");
lifecycle.verifyCheckpoint(checkpoint);
if (claimGeneration && !recreated.successorGeneration) {
const claimed = this.platform.db.prepare("UPDATE environment_operations SET receipt_json=? WHERE operation_id=? AND receipt_json=?")
.run(JSON.stringify({ ...recreated, successorGeneration: binding.launchGeneration }), row.operation_id, row.receipt_json);
if (!claimed.changes) throw new EnvironmentError("environment_selection_changed", "The successor launch generation was already claimed");
}
return checkpoint;
}
private projectionFence(binding: NodeBinding, claimGeneration = true): void {
const selected = this.selected(binding);
const executions = this.platform.db.prepare("SELECT request_json FROM environment_operations WHERE binding_id=? AND kind='launch' AND state IN ('intent','requested','confirmed')").all(selected.bindingId) as Array<{ request_json: string }>;
if (executions.some(row => JSON.parse(row.request_json).resourceId === selected.resource!.id))
throw new EnvironmentError("environment_prior_execution_unconfirmed", "An existing execution on this compute must be reconciled before projection; no running inputs were overwritten");
this.retainedResume(binding, undefined, claimGeneration);
}
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; resumeType?: string; forkSource?: unknown; beforeNativeDispatch?: () => boolean }, tmux: Pick<TmuxAdapter, "sendShellCommand">): Promise<HarnessLaunchResult> {
if (opts.forkSource) return { ok: false, recovery: "attention_required", error: "Environment fork is unsupported; no fresh conversation was started." };
const checkpoint = this.retainedResume(binding, opts);
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: checkpoint?.checkpointId ?? null, predecessorResourceId: checkpoint?.predecessorResourceId ?? 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 && !checkpoint) 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[];
for (const required of checkpoint?.context ?? []) {
if (!files.some(file => file.path === required.path && file.sha256 === required.sha256 && file.bytes === required.bytes))
throw new EnvironmentError("environment_projection_missing", "The recreated destination is missing its verified retained context");
}
const nativeId = checkpoint?.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 {
if (checkpoint) {
// Exact private state is an explicit transfer; current role/context is staged separately without editing history.
for (const file of [...checkpoint.files, ...(checkpoint.artifacts ?? [])]) {
const destination = posix.join(selected.guestRoot, file.path);
let existing: Buffer | undefined;
try { existing = await this.platform.driver.read(selected, destination); }
catch (error) { if (!(error instanceof EnvironmentError && error.code === "environment_file_missing")) throw error; }
if (existing && sha256(existing) !== file.sha256) throw new EnvironmentError("environment_checkpoint_changed", "A successor native-state destination already has different bytes");
if (!existing) await this.platform.driver.upload(selected, file.source, destination);
const received = await this.platform.driver.read(selected, destination);
if (sha256(received) !== file.sha256 || received.length !== file.bytes) throw new EnvironmentError("environment_checkpoint_changed", "Retained native state did not arrive unchanged");
}
}
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, ...(checkpoint ? { resumeToken: checkpoint.nativeId, nativeType: checkpoint.nativeType, checkpoint: { nativeId: checkpoint.nativeId, nativeType: checkpoint.nativeType, files: [...checkpoint.files, ...(checkpoint.artifacts ?? [])].map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) } } : {}), 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 || observed.running !== true) 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: checkpoint?.checkpointId ?? null, predecessorResourceId: checkpoint?.predecessorResourceId ?? 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 }); }
}
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { sessionCommunityRunnerSelect } from "./session-registry.js";
import { physicalSessionName } from "./session-registry.js";
import fs from "node:fs";
import type { CaptureObserver, ObservedBinding } from "./capture-observer.js";
@@ -67,6 +68,7 @@ interface InventoryRow {
session_created_at: string | null;
session_status: string | null;
session_community_harness?: string | null;
session_community_runner?: number | null;
intentionally_stopped: number | null;
startup_status: string | null;
resume_type: string | null;
@@ -669,6 +671,7 @@ function runInventoryRowQuery(db: Database.Database, whereClause: string, orderC
s.created_at as session_created_at,
s.status as session_status,
${sessionCommunityHarnessSelect(db)} as session_community_harness,
${sessionCommunityRunnerSelect(db)} as session_community_runner,
s.intentionally_stopped,
s.startup_status,
s.resume_type,
@@ -786,7 +789,7 @@ function buildInventoryEntry(
attachmentType: (row.binding_attachment_type as NodeInventoryEntry["attachmentType"]) ?? null,
nodeKind: deriveNodeKind(row.runtime),
runtime: row.runtime,
...(communityHarnessOf(row.session_community_harness, row.runtime) ? { communityHarness: communityHarnessLabel(row.runtime!, undefined, { resumeType: row.resume_type, resumeToken: row.resume_token, resumeProvenance: row.resume_provenance ?? null }) } : {}),
...(communityHarnessOf(row.session_community_harness, row.runtime) ? { communityHarness: communityHarnessLabel(row.runtime!, undefined, { resumeType: row.resume_type, resumeToken: row.resume_token, resumeProvenance: row.resume_provenance ?? null, runner: row.session_community_runner === 1 }) } : {}),
sessionStatus,
storedSessionStatus: row.session_status,
// An explicit down records a failed start's rolled-back session without changing its superseded status.
@@ -319,6 +319,7 @@ export class RestoreOrchestrator {
* Launch preparation and native existence checks remain on the execution path. */
canAttemptRecordedResume(runtime: string | null, resumeType: string | null, resumeToken: string | null): boolean {
if (typeof resumeToken !== "string" || !resumeToken.trim()) return false;
if (runtime === "openshell-claude") return resumeType === "community:openshell-claude";
if (runtime === "codex") return resumeType === "codex_id" && this.codexResume.canResume(resumeType, resumeToken);
if (runtime === "claude-code") return this.claudeResume.canResume(resumeType, resumeToken);
return false;
@@ -1742,6 +1743,8 @@ export class RestoreOrchestrator {
const replayContained = resumeRequested && !!resumeToken;
const startupRuntime = startupCtx?.runtime ?? node.runtime ?? null;
const startupAdapter = seatAdapter(opts?.adapters, startupRuntime, session?.communityHarness);
// New compute has no retained context filesystem. Restore the saved file selection, never conversation actions.
const restoreEnvironmentContext = replayContained && !!startupAdapter?.environment && !!data.environmentBindingsByNode?.[node.id];
// A new pod-aware agent needs a startup context to run StartupOrchestrator.
// Do not report a healthy fresh-primed result when that context or adapter
@@ -1808,9 +1811,9 @@ export class RestoreOrchestrator {
if (adapter) {
// Prefilter: check which files/entries still exist
const existsFn = opts.fsOps?.exists ?? (() => true);
const sourceEntries = replayContained ? [] : startupCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
const sourceEntries = replayContained && !restoreEnvironmentContext ? [] : startupCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
// #261: recognized built-in startup files follow the running install.
const sourceFiles = replayContained ? [] : startupCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn));
const sourceFiles = replayContained && !restoreEnvironmentContext ? [] : startupCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn));
const sourceActions = replayContained ? [] : startupCtx.startupActions;
const filteredEntries = sourceEntries.filter((e) => {
if (!existsFn(e.absolutePath)) {
@@ -1869,6 +1872,7 @@ export class RestoreOrchestrator {
const binding = {
...launchResult.binding,
...(data.environmentBindingsByNode?.[node.id] ? { environmentRestore: data.environmentBindingsByNode[node.id] } : {}),
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
cwd: node.cwd ?? ".",
codexConfigProfile: this.liveCodexProfile(rigId, node, selected?.nodeId) ?? undefined,
@@ -8,6 +8,7 @@ import type { ClaudeResumeRotation } from "./native-process-lineage.js";
// The startup orchestrator (AS-T07) constructs NodeBinding from Binding + node.cwd.
export interface NodeBinding extends Binding {
environmentRestore?: NonNullable<import("./types.js").SnapshotData["environmentBindingsByNode"]>[string];
cwd: string;
/** In-place Claude continuation only: executable observed before the old process exits. */
claudeExecutable?: string;
@@ -222,6 +223,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; resumeType?: 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;
@@ -1,3 +1,4 @@
import { communityHarnessOf } from "./built-in-runtimes.js";
import { readClaudePermissionRules, validateClaudePermissionRules } from "./claude-permission-rules.js";
import { clearCodexMemberPermissions, readCodexMemberPermissions, validateCodexMemberPermissions, type CodexProfileObserver } from "./codex-member-permissions.js";
import { parseTerminalPolicy, TerminalPolicyRefusal, TerminalPolicyStore, type TerminalAction } from "./terminal-policy.js";
@@ -226,7 +227,7 @@ export type SetEffortResult =
| SeatRefusal;
export type StopSeatResult =
| { ok: true; seat: SeatDescriptor; sessionName: string; sessionId: string }
| { ok: true; seat: SeatDescriptor; sessionName: string; sessionId: string; paneOnly?: boolean }
| (SeatRefusal & { partialEffect?: boolean; stopPhase?: StopPhase });
/** The narrow identity reviewed before an optional checked stop. */
@@ -1917,7 +1918,8 @@ export class SeatLifecycleService {
if (!checked) throw error;
return { ok: false, code: "stop_uncertain", message: `Session kill completed, but stop finalization failed: ${(error as Error).message}`, partialEffect: true, stopPhase: "kill_attempted" };
}
return { ok: true, seat, sessionName: session.session_name, sessionId: session.id };
return { ok: true, seat, sessionName: session.session_name, sessionId: session.id,
...(communityHarnessOf(session.community_harness, (this.db.prepare("SELECT runtime FROM nodes WHERE id=?").get(resolved.nodeId) as { runtime: string | null } | undefined)?.runtime) ? { paneOnly: true } : {}) };
}
private nonTerminalSessions(nodeId: string): LatestSessionRow[] {
@@ -125,6 +125,18 @@ export class SnapshotCapture {
} catch { /* receipt_only — no checkpoint available */ }
}
const environmentBindingsByNode: NonNullable<SnapshotData["environmentBindingsByNode"]> = {};
if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='environment_bindings'").get()) {
for (const node of rig.nodes) {
const binding = this.db.prepare("SELECT binding_id,resource_json,generation,config_json FROM environment_bindings WHERE node_id=?").get(node.id) as { binding_id: string; resource_json: string | null; generation: string | null; config_json: string } | undefined;
if (!binding?.resource_json) continue;
const checkpoint = this.db.prepare("SELECT receipt_json FROM environment_operations WHERE binding_id=? AND kind='checkpoint' AND state='confirmed' ORDER BY rowid DESC LIMIT 1").get(binding.binding_id) as { receipt_json: string } | undefined;
const cp = checkpoint ? JSON.parse(checkpoint.receipt_json) : null, config = JSON.parse(binding.config_json);
environmentBindingsByNode[node.id] = { version: 1, bindingId: binding.binding_id, resourceId: JSON.parse(binding.resource_json).id, generation: binding.generation,
checkpointId: cp?.checkpointId ?? null, nativeType: cp?.nativeType ?? null, nativeId: cp?.nativeId ?? null,
destinationCwd: `${config.guestRoot}/work`, nativeState: `${config.guestRoot}/native` };
}
}
// 5. Assemble SnapshotData
const data: SnapshotData = {
rig: rig.rig,
@@ -143,6 +155,7 @@ export class SnapshotCapture {
intendedNodeIds,
},
checkpoints,
...(Object.keys(environmentBindingsByNode).length ? { environmentBindingsByNode } : {}),
pods: podRows.map((p) => ({ id: p.id, rigId: p.rig_id, namespace: p.namespace, label: p.label, summary: p.summary, continuityPolicyJson: p.continuity_policy_json, createdAt: p.created_at })),
continuityStates: continuityRows.map((r) => ({ podId: r.pod_id, nodeId: r.node_id, status: r.status as "healthy" | "degraded" | "restoring", artifactsJson: r.artifacts_json, lastSyncAt: r.last_sync_at, updatedAt: r.updated_at })),
nodeStartupContext,
@@ -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, resumeType: input.resumeType, 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");
@@ -441,7 +452,8 @@ export class StartupOrchestrator {
// so the operator's trust-precedence ordering is preserved when the post-launch
// delivery loop walks the array. Rebuild artifacts are tagged
// appliesOn: ["fresh_start"] by the resolver, which matches the rebuild context.
const sourceFiles = input.rebuildArtifacts && input.rebuildArtifacts.length > 0
const restoreEnvironmentContext = !!input.adapter.environment && !!input.binding.environmentRestore && !!input.preserveStartupContext;
const sourceFiles = restoreEnvironmentContext ? [] : input.rebuildArtifacts && input.rebuildArtifacts.length > 0
? [...input.rebuildArtifacts, ...input.resolvedStartupFiles]
: input.resolvedStartupFiles;
// OPR.0.7.0.13 (router, R1.1): the one-line bootstrap is delivered on its own (below), only with a yes, and never
@@ -451,6 +463,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(restoreEnvironmentContext ? input.resolvedStartupFiles : 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) {
+1
View File
@@ -391,6 +391,7 @@ export interface NodeStartupSnapshot {
}
export interface SnapshotData {
environmentBindingsByNode?: Record<string, { version: 1; bindingId: string; resourceId: string; generation: string | null; checkpointId: string | null; nativeType: string | null; nativeId: string | null; destinationCwd: string; nativeState: string }>;
rig: Rig;
nodes: NodeWithBinding[];
edges: Edge[];
+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}`);
@@ -0,0 +1,71 @@
import { Hono, type Context } from "hono";
import { EnvironmentPlatform, EnvironmentError, type EnvironmentConfig } from "../domain/environment-platform.js";
import { EnvironmentLifecycle } from "../domain/environment-lifecycle.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);
const operations = service.db.prepare("SELECT operation_id AS operationId,kind,state,receipt_json FROM environment_operations WHERE binding_id=? ORDER BY rowid").all(binding.bindingId) as Array<{ operationId: string; kind: string; state: string; receipt_json: string | null }>;
return c.json({ ...binding, operations: operations.map(({ receipt_json, ...operation }) => ({ ...operation, receipt: receipt_json ? JSON.parse(receipt_json) : null })), observation: c.req.query("live") === "true" ? "live-resource" : "stored-receipt",
lifecycle: { closeViewer: "explicit effect", stopNative: "owned exact-native effect", stopCompute: "owned exact-compute effect", deleteEnvironment: "owned effect with independent local engine inventory", nativeRestore: "verified checkpoint and explicitly recreated same-host compute" } });
} 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); }
});
// Requester audit is trusted transport attribution, not credential-backed seat authentication.
// The transport-derived requester supersedes any requestedBy claim in the body.
environmentRoutes.post("/:bindingId/effect", async c => {
const identity = requireSenderIdentity(c, { verb: "environment effect" }); if (!identity.ok) return identity.response;
try { const body = { ...await c.req.json<{ effect: string }>(), requestedBy: identity.session }; return c.json(await (c.get("environmentLifecycle" as never) as EnvironmentLifecycle).effect(c.req.param("bindingId"), body as never)); }
catch (error) { return failure(c, error); }
});
for (const operation of ["checkpoint", "recreate", "completion"] as const) environmentRoutes.post(`/:bindingId/${operation}`, async c => {
const identity = requireSenderIdentity(c, { verb: `environment ${operation}` }); if (!identity.ok) return identity.response;
try {
const service = c.get("environmentLifecycle" as never) as EnvironmentLifecycle, input = { ...await c.req.json(), requestedBy: identity.session };
const result = operation === "checkpoint" ? await service.checkpoint(c.req.param("bindingId"), input)
: operation === "recreate" ? await service.recreate(c.req.param("bindingId"), input) : service.selectCompletion(c.req.param("bindingId"), input);
if (operation === "completion") await service.checkCompletions();
return c.json(result);
} catch (error) { return failure(c, error); }
});
+51 -1
View File
@@ -1,3 +1,7 @@
import { EnvironmentLifecycle } from "./domain/environment-lifecycle.js";
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 +66,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 +150,9 @@ import { createRouteTimingMiddleware } from "./domain/route-timing-recorder.js";
import { browserBoundary, type BrowserBoundaryOptions } from "./middleware/browser-boundary.js";
export interface AppDeps {
environmentDriver?: EnvironmentDriver;
environmentArtifactRoot?: string;
environmentStateRoot?: string;
/** Boot-time DB context observations, not a live exclusion guarantee. */
databaseWarnings?: string[];
proofSourceWatch?: import("./domain/proof/source-watch.js").ProofSourceWatch;
@@ -446,6 +453,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 +523,29 @@ 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 environmentLifecycle = new EnvironmentLifecycle(environmentPlatform, deps.queueRepo,
deps.environmentStateRoot ?? nodePath.join(process.cwd(), ".openrig-environment-state"), async binding => {
const current = deps.sessionRegistry.getBindingForNode(binding.nodeId!);
if (!current?.tmuxSession || current.tmuxSession !== binding.seat) return { confirmed: false };
const result = await deps.tmuxAdapter.killSession(current.tmuxSession, () => {
const now = deps.sessionRegistry.getBindingForNode(binding.nodeId!);
if (now?.id !== current.id || now.tmuxSession !== current.tmuxSession) throw new Error("The selected viewer changed");
});
return { confirmed: result.ok || result.code === "session_not_found" };
});
let completionPending = false, completionDirty = false;
const requestCompletionCheck = () => {
completionDirty = true;
if (completionPending) return;
completionPending = true;
void (async () => {
while (completionDirty) { completionDirty = false; await environmentLifecycle.checkCompletions(); }
})().catch(() => {}).finally(() => { completionPending = false; });
};
deps.eventBus.subscribe(event => { if (event.type === "queue.updated") requestCompletionCheck(); });
// Reconcile durable selected completions after a daemon restart; construction itself invokes no provider.
queueMicrotask(requestCompletionCheck);
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 +557,9 @@ 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("environmentLifecycle" as never, environmentLifecycle);
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 +841,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());
+25 -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,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const deps: AppDeps = {
environmentDriver,
environmentArtifactRoot: nodePath.join(runtimeSettings.workspaceRoot, "artifacts", "environments"),
environmentStateRoot: nodePath.join(OPENRIG_HOME, "state", "environment-checkpoints"),
databaseWarnings,
rigRepo,
sessionRegistry,
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
import { Hono } from "hono";
import { loadCommunityHarnessPreset } from "../src/domain/community-harness-presets.js";
import { communityHarnessLabel, loadCommunityHarnessPreset } from "../src/domain/community-harness-presets.js";
import { CommunityHarnessRuntimeAdapter } from "../src/adapters/community-harness-runtime-adapter.js";
import { migrate } from "../src/db/migrate.js";
import { RigRepository } from "../src/domain/rig-repository.js";
@@ -59,6 +59,15 @@ describe("declaring a runner preset", () => {
});
});
it("labels runner activity from the recorded launch, never a later preset edit", () => {
const dir = presetDir(BASE + "launch_result: true\nrunner: true\n");
const session = { resumeType: null, resumeToken: null, resumeProvenance: null, runner: true };
expect(communityHarnessLabel("synth", dir, session).needsInput).toBe("from structured activity and screen observations");
writeFileSync(join(dir, "synth.yaml"), BASE);
expect(communityHarnessLabel("synth", dir, session).needsInput).toBe("from structured activity and screen observations");
expect(communityHarnessLabel("synth", dir, { ...session, runner: false }).needsInput).toBe("from the screen only: no harness prompt pattern");
});
describe("the runner declaration recorded with the launch", () => {
function seat(db: Database.Database) {
const registry = new SessionRegistry(db);
@@ -0,0 +1,203 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { EnvironmentLifecycle } from "../src/domain/environment-lifecycle.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { EventBus } from "../src/domain/event-bus.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
import { EnvironmentPlatform, sha256 } from "../src/domain/environment-platform.js";
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, automatic = true) {
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 queueEvents = new EventBus(db), queue = new QueueRepository(db, queueEvents); queue.attachOutbox(new OutboxHandler(db));
const f = createTestApp(db, { appDeps: { eventBus: automatic ? queueEvents : new EventBus(db), queueRepo: queue, 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) } : {}) }); return { status: res.status, body: await res.json() as Record<string, any> }; };
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 });
const lifecycle = new EnvironmentLifecycle(new EnvironmentPlatform(db, driver), queue, join(root, "retained"));
return { root, db, driver, queue, lifecycle, request, effect, resource, native, files, history, capture: () => f.snapshotCapture.captureSnapshot(rig.id, "manual"), disappear: () => { present = false; }, 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("carries nonsecret environment/native/checkpoint references in the ordinary snapshot", async () => {
const f = await fixture(); await f.request("/binding-one/checkpoint", { operationId: "checkpoint-one", generation: "generation-one" });
const snapshot = f.capture(), references = Object.values(snapshot.data.environmentBindingsByNode ?? {});
expect(references).toHaveLength(1);
expect(references[0]).toMatchObject({ bindingId: "binding-one", resourceId: "compute-one", checkpointId: "checkpoint-one", nativeId: f.native.nativeId, nativeType: f.native.nativeType, nativeState: "/sandbox/openrig/native" });
expect(JSON.stringify(snapshot.data.environmentBindingsByNode)).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("recreates only after verified predecessor absence and carries the exact retained native id", async () => {
const f = await fixture(), checkpoint = await f.request("/binding-one/checkpoint", { operationId: "checkpoint-one", generation: "generation-one" });
expect(checkpoint.status).toBe(200); f.disappear();
const policy = join(f.root, "policy.yaml");
// The provider fixture returns a different immutable ID on explicit successor creation.
f.resource.id = "compute-two"; f.resource.name = "worker-two";
const result = await f.request("/binding-one/recreate", { operationId: "recreate-one", generation: "generation-one", checkpointId: "checkpoint-one", resumeType: "community:openshell-claude", resumeToken: f.native.nativeId,
placement: { gateway: "gateway", workspace: "workspace", name: "worker-two", image: `image@sha256:${"a".repeat(64)}`, provider: "new-provider", policy } });
expect(result.status).toBe(200); expect(result.body).toMatchObject({ state: "confirmed", predecessorResourceId: "compute-one", successorResourceId: "compute-two", nativeId: f.native.nativeId });
});
it("refuses changed private history before any successor create", async () => {
const f = await fixture(), checkpoint = await f.request("/binding-one/checkpoint", { operationId: "checkpoint-one", generation: "generation-one" });
expect(checkpoint.status).toBe(200); writeFileSync(checkpoint.body.files[0].source, "changed history"); f.disappear();
const result = await f.request("/binding-one/recreate", { operationId: "recreate-one", generation: "generation-one", checkpointId: "checkpoint-one", resumeType: "community:openshell-claude", resumeToken: f.native.nativeId, placement: {} });
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);
});
});
describe("finite completion is selected work plus a verified actual export", () => {
async function selected(automatic = true) {
const f = await fixture(false, automatic);
await f.queue.create({ qitemId: "task-one", sourceSession: "coordinator@lifecycle", destinationSession: "worker@lifecycle", body: "Produce the report", nudge: false });
f.lifecycle.selectCompletion("binding-one", { operationId: "completion-one", generation: "generation-one", taskId: "task-one", exportOperationId: "export-one", effect: "stop-native" });
const publish = () => {
const path = join(f.root, "verified-report.json"), bytes = Buffer.from("actual report bytes"); writeFileSync(path, bytes);
const receipt = { bindingId: "binding-one", generation: "generation-one", taskId: "task-one", path, bytes: bytes.length, sha256: sha256(bytes) };
f.db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state,receipt_json) VALUES ('export-one','binding-one','export','{}','confirmed',?)").run(JSON.stringify(receipt));
return path;
};
const done = () => f.queue.update({ qitemId: "task-one", actorSession: "worker@lifecycle", state: "done", closureReason: "no-follow-on", transitionNote: "Verified result complete" });
return { ...f, publish, done };
}
it("does not stop on an empty/unsettled queue or a pending export", async () => {
const f = await selected(); await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(0);
f.done(); await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(0);
f.publish(); await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(1);
await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(1);
});
it("the actual queue event schedules selected completion without another manual check", async () => {
const f = await selected(); f.publish(); f.done();
for (let turn = 0; turn < 20 && !f.counts().stopCalls; turn++) await new Promise(resolve => setTimeout(resolve, 5));
expect(f.counts().stopCalls).toBe(1);
});
it("another seat's same-state note does not undo a settled selected task", async () => {
const f = await selected(); f.done();
f.queue.update({ qitemId: "task-one", actorSession: "observer@lifecycle", transitionNote: "Result noted" });
f.publish(); await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(1);
});
it("keeps failed work and another task's export from triggering completion", async () => {
const f = await selected(); f.publish();
f.queue.update({ qitemId: "task-one", actorSession: "worker@lifecycle", state: "failed", transitionNote: "Task failed" });
await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(0);
});
it("preserves execution when the returned artifact changed after its receipt", async () => {
const f = await selected(), path = f.publish(); f.done(); writeFileSync(path, "changed host artifact");
await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(0);
});
it("does not stop a successor generation for an old task's successful close", async () => {
const f = await selected(); f.publish(); f.done();
f.db.prepare("UPDATE environment_bindings SET generation='generation-two' WHERE binding_id='binding-one'").run();
await f.lifecycle.checkCompletions(); expect(f.counts().stopCalls).toBe(0);
});
it.each(["stop-native", "stop-compute"] as const)("rechecks selected completion %s after awaited provider observation", async effect => {
for (const change of ["task", "artifact", "generation"] as const) {
const f = await selected(false), artifact = f.publish();
f.db.prepare("UPDATE environment_operations SET request_json=json_set(request_json,'$.effect',?) WHERE operation_id='completion-one'").run(effect);
f.done();
let enter!: () => void, release!: () => void;
const entered = new Promise<void>(resolve => { enter = resolve; }), gate = new Promise<void>(resolve => { release = resolve; });
const inspect = f.driver.inspect;
f.driver.inspect = async () => { enter(); await gate; return inspect(); };
const attempt = f.lifecycle.checkCompletions(); await entered;
if (change === "task") f.queue.update({ qitemId: "task-one", actorSession: "worker@lifecycle", state: "in-progress", reopen: true, transitionNote: "Reopen selected work" });
if (change === "artifact") writeFileSync(artifact, "changed while observing provider");
if (change === "generation") f.db.prepare("UPDATE environment_bindings SET generation='generation-two' WHERE binding_id='binding-one'").run();
release(); await attempt; expect(f.counts().stopCalls, change).toBe(0);
const op = f.db.prepare("SELECT state FROM environment_operations WHERE operation_id LIKE 'completion-stop-%'").get() as { state: string } | undefined;
expect(op?.state, change).not.toBe("requested");
}
});
it("bounds pending completion observations and retains an inspectable last attempt", async () => {
const f = await selected(false); f.publish(); f.done();
let clock = Date.now(); const now = vi.spyOn(Date, "now").mockImplementation(() => clock); cleanup.push(() => now.mockRestore());
let inspections = 0, signals = 0;
const inspect = f.driver.inspect;
f.driver.inspect = async () => { inspections++; return inspect(); };
f.driver.stopNative = async () => { signals++; }; // Native remains present after its one TERM.
await f.lifecycle.checkCompletions(); const first = inspections;
for (let event = 0; event < 5; event++) await f.lifecycle.checkCompletions();
expect(inspections).toBe(first); expect(signals).toBe(1);
const op = f.db.prepare("SELECT state,receipt_json FROM environment_operations WHERE operation_id='completion-one'").get() as { state: string; receipt_json: string };
expect(op.state).toBe("requested"); expect(JSON.parse(op.receipt_json)).toMatchObject({ state: "pending", lastAttemptAt: clock });
clock += 60_001; await f.lifecycle.checkCompletions(); expect(inspections).toBeGreaterThan(first); expect(signals).toBe(1);
});
});
@@ -0,0 +1,241 @@
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);
f.db.prepare("UPDATE environment_bindings SET generation='attached-generation' WHERE binding_id='binding-one'").run();
const denied = await f.request("/binding-one/effect", { operationId: "attached-delete", generation: "attached-generation", effect: "delete-owned" });
expect(denied.status).toBe(409); expect(denied.body.code).toBe("environment_attached_preserved");
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,234 @@
import { afterEach, expect, it, vi } from "vitest";
import { mkdtempSync, realpathSync, readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } 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, type EnvironmentBinding } from "../src/domain/environment-platform.js";
import { EnvironmentLifecycle } from "../src/domain/environment-lifecycle.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 { CommunityHarnessRuntimeAdapter } from "../src/adapters/community-harness-runtime-adapter.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, running: true };
});
// 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, app, node, rig, files, resource, 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();
await expect(f.startup.launch(f.binding, { resumeToken: "saved-conversation", resumeType: "community:openshell-claude" }, f.tmux)).rejects.toMatchObject({ code: "environment_checkpoint_missing" });
expect(f.activity).not.toHaveBeenCalled(); expect(f.tmux.sendShellCommand).not.toHaveBeenCalled();
});
it("preserves running destination bytes and export generation before an attempted new-generation projection", async () => {
const f = await fixture(); expect((await f.startup.launch(f.binding, {}, f.tmux)).ok).toBe(true);
const before = sha256(f.files.get("/sandbox/openrig/work/CLAUDE.md")!);
const plan = { runtime: "openshell-claude", cwd: f.root, entries: [], conflicts: [], noOps: [], diagnostics: [], startup: { files: [], actions: [] } } as ProjectionPlan;
await expect(f.startup.project(plan, { ...f.binding, launchGeneration: "generation-two" })).rejects.toMatchObject({ code: "environment_prior_execution_unconfirmed" });
expect(sha256(f.files.get("/sandbox/openrig/work/CLAUDE.md")!)).toBe(before);
expect(f.platform.get("binding-one")?.generation).toBe("generation-one"); expect(f.activity).toHaveBeenCalledTimes(1);
});
it("transfers exact retained history onto explicitly recreated compute and requests the original native conversation", async () => {
const f = await fixture(); const first = await f.startup.launch(f.binding, {}, f.tmux);
expect(first.ok).toBe(true); if (!first.ok || !first.resumeToken) throw new Error("Missing native receipt");
const path = `native/projects/-sandbox-openrig-work/${first.resumeToken}.jsonl`;
const history = Buffer.from(JSON.stringify({ sessionId: first.resumeToken, type: "user", message: { content: "retained earlier context" } }) + "\n");
f.files.set(`/sandbox/openrig/${path}`, history);
Object.assign(f.platform.driver, { checkpointFiles: async () => [{ path, sha256: sha256(history), bytes: history.length }],
captureCleanup: undefined, cleanupState: async () => ({ workload: false, supervisor: false, volumes: false }) });
const lifecycle = new EnvironmentLifecycle(f.platform, undefined, join(f.root, "retained"));
const checkpoint = await lifecycle.checkpoint("binding-one", { operationId: "checkpoint-one", generation: "generation-one" });
f.resource.id = "successor-resource"; f.resource.name = "worker-two"; f.files.clear();
const placement = { ...f.platform.get("binding-one")!.placement, name: "worker-two" };
const recreated = await lifecycle.recreate("binding-one", { operationId: "recreate-one", generation: "generation-one", checkpointId: checkpoint.checkpointId,
resumeType: "community:openshell-claude", resumeToken: first.resumeToken, placement });
expect(recreated).toMatchObject({ predecessorResourceId: "resource-one", successorResourceId: "successor-resource", nativeId: first.resumeToken });
const binding = { ...f.binding, launchGeneration: "generation-two" };
const plan = { runtime: "openshell-claude", cwd: f.root, entries: [], conflicts: [], noOps: [], diagnostics: [], startup: { files: [], actions: [] } } as ProjectionPlan;
await f.startup.preflight(binding, { resumeToken: first.resumeToken, resumeType: "community:openshell-claude" });
await f.startup.project(plan, binding);
const result = await f.startup.launch(binding, { resumeToken: first.resumeToken, resumeType: "community:openshell-claude" }, f.tmux);
expect(result).toMatchObject({ ok: true, resumeToken: first.resumeToken, resumeType: "community:openshell-claude" });
expect(f.files.get(`/sandbox/openrig/${path}`)).toEqual(history);
const descriptor = JSON.parse(f.files.get("/sandbox/openrig/control/launch.pending.json")!.toString());
expect(descriptor.resumeToken).toBe(first.resumeToken); expect(descriptor.nativeId).toBe(first.resumeToken);
expect(descriptor.checkpoint.files[0]).toMatchObject({ path, sha256: sha256(history), bytes: history.length });
expect(f.activity).toHaveBeenCalledTimes(2); expect(f.tmux.sendShellCommand).toHaveBeenCalledTimes(2);
});
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);
});
it.each(["unchanged", "missing-source", "changed", "added", "removed", "stale-destination", "missing-destination", "missing-history", "changed-history"])("normal exact restore on recreated compute verifies current destination context (%s) without conversation replay", async mode => {
const f = await fixture();
// Normal restore reads the installed preset, rather than the adapter constructor alone.
const home = join(f.root, "openrig-home"), presets = join(home, "community-harnesses");
mkdirSync(presets, { recursive: true });
writeFileSync(join(presets, "openshell-claude.yaml"), readFileSync(new URL("../assets/openshell/preset.yaml", import.meta.url)));
vi.stubEnv("OPENRIG_HOME", home); cleanup.push(() => vi.unstubAllEnvs());
const role = join(f.root, "role.md"), skill = join(f.root, "skill"), plugin = join(f.root, "plugin"), startupFile = join(f.root, "startup.md");
mkdirSync(skill); mkdirSync(join(plugin, ".claude-plugin"), { recursive: true });
writeFileSync(role, "Required restored role"); writeFileSync(join(skill, "SKILL.md"), "Required restored skill");
writeFileSync(join(plugin, ".claude-plugin/plugin.json"), '{"name":"restored-plugin","version":"1.0.0"}');
writeFileSync(startupFile, "Required filesystem startup, never conversation input");
writeFileSync(join(skill, "old-reference.md"), "Old declared skill reference");
const entries = [["guidance", "role", role], ["skill", "skill", skill], ["plugin", "plugin", plugin]].map(([category, effectiveId, absolutePath]) =>
({ category, effectiveId, absolutePath, resourcePath: absolutePath, sourcePath: absolutePath, sourceSpec: "fixture", classification: "safe_projection", mergeStrategy: "managed_block" }));
const files = [{ path: "startup.md", absolutePath: startupFile, ownerRoot: f.root, required: true, deliveryHint: "send_text", appliesOn: ["fresh_start"] }];
const plan = { runtime: "openshell-claude", cwd: f.root, entries, conflicts: [], noOps: [], diagnostics: [], startup: { files, actions: [] } } as unknown as ProjectionPlan;
await f.startup.project(plan, f.binding);
await f.startup.deliverStartup(files as never, f.binding);
const first = await f.startup.launch(f.binding, {}, f.tmux);
const nativePath = `native/projects/-sandbox-openrig-work/${first.resumeToken}.jsonl`;
const history = Buffer.from(JSON.stringify({ sessionId: first.resumeToken, type: "user", message: { content: "unchanged retained history" } }) + "\n");
f.files.set(`/sandbox/openrig/${nativePath}`, history);
Object.assign(f.platform.driver, { checkpointFiles: async () => [{ path: nativePath, sha256: sha256(history), bytes: history.length }],
captureCleanup: async (binding: EnvironmentBinding, socket: string) => ({ version: 1, host: "fixture", uid: 1000, socket, socketDevice: 1, socketInode: 2,
resourceId: binding.resource!.id, workspace: binding.placement.workspace, workloadId: "a".repeat(64), supervisorId: "b".repeat(64), volumes: [] }),
cleanupState: async () => ({ workload: false, supervisor: false, volumes: false }) });
const lifecycle = new EnvironmentLifecycle(f.platform, undefined, join(f.root, "retained"));
const checkpoint = await lifecycle.checkpoint("binding-one", { operationId: "context-checkpoint", generation: "generation-one" });
f.db.prepare("INSERT INTO pods(id,rig_id,label) VALUES ('restore-pod',?,'Team')").run(f.rig.id);
f.db.prepare("UPDATE nodes SET pod_id='restore-pod' WHERE id=?").run(f.node.id);
const session = f.app.sessionRegistry.registerSession(f.node.id, f.binding.tmuxSession!);
f.app.sessionRegistry.updateStatus(session.id, "running");
f.app.sessionRegistry.recordLauncherReportedToken(session.id, "community:openshell-claude", first.resumeToken!);
f.app.sessionRegistry.recordCommunityHarnessSelection(session.id, "openshell-claude", false);
f.db.prepare("INSERT INTO node_startup_context(node_id,projection_entries_json,resolved_files_json,startup_actions_json,runtime) VALUES (?,?,?,?,?)")
.run(f.node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify([{ type: "send_text", text: "NEVER REPLAY", phase: "after_ready" }]), "openshell-claude");
const snapshot = f.app.snapshotCapture.captureSnapshot(f.rig.id, "manual");
// Delete captures the predecessor's actual host/engine before recreate compares its successor.
expect(await lifecycle.effect("binding-one", { operationId: "context-delete", generation: "generation-one", effect: "delete-owned", engineSocket: "/owned/engine.sock" }))
.toMatchObject({ state: "confirmed", remaining: { workload: false, supervisor: false, volumes: false } });
const inventory = f.db.prepare("SELECT receipt_json FROM environment_operations WHERE operation_id='inventory-resource-one'").get() as { receipt_json: string };
expect(JSON.parse(inventory.receipt_json)).toMatchObject({ host: "fixture", uid: 1000, socket: "/owned/engine.sock", socketDevice: 1, socketInode: 2, resourceId: "resource-one" });
f.resource.id = "successor-resource"; f.resource.name = "worker-successor";
await lifecycle.recreate("binding-one", { operationId: "context-recreate", generation: "generation-one", checkpointId: checkpoint.checkpointId,
resumeType: "community:openshell-claude", resumeToken: first.resumeToken!, placement: { ...f.platform.get("binding-one")!.placement, name: "worker-successor" } });
f.files.clear();
f.app.sessionRegistry.updateStatus(session.id, "exited"); f.app.sessionRegistry.clearBinding(f.node.id);
if (mode === "missing-source") rmSync(startupFile);
if (mode === "changed") writeFileSync(join(skill, "SKILL.md"), "Current skill revision for report 2");
if (mode === "added") writeFileSync(join(skill, "new-reference.md"), "Current added skill reference");
if (mode === "removed") rmSync(join(skill, "old-reference.md"));
if (mode === "missing-history") rmSync(checkpoint.files[0].source);
if (mode === "changed-history") writeFileSync(checkpoint.files[0].source, "Changed retained history");
const upload = f.platform.driver.upload.bind(f.platform.driver);
if (mode === "stale-destination" || mode === "missing-destination") {
vi.spyOn(f.platform.driver, "upload").mockImplementation(async (...args) => {
await upload(...args);
if (args[2] === "/sandbox/openrig/work/.claude/skills/skill/SKILL.md") {
if (mode === "stale-destination") f.files.set(args[2], Buffer.from("Stale destination revision"));
else f.files.delete(args[2]);
}
});
}
const nativePrepare = f.guest.getMockImplementation()!;
let restored = false;
f.guest.mockImplementation(async (binding, args) => {
if (args[0] === "--prepare") {
restored = true;
expect(f.files.get("/sandbox/openrig/work/.openrig/context/role")?.toString()).toBe("Required restored role");
expect(f.files.get("/sandbox/openrig/work/.claude/skills/skill/SKILL.md")?.toString()).toBe(mode === "changed" ? "Current skill revision for report 2" : "Required restored skill");
if (mode === "added") expect(f.files.get("/sandbox/openrig/work/.claude/skills/skill/new-reference.md")?.toString()).toBe("Current added skill reference");
if (mode === "removed") expect(f.files.has("/sandbox/openrig/work/.claude/skills/skill/old-reference.md")).toBe(false);
expect(f.files.get("/sandbox/openrig/work/.claude/plugins/plugin/.claude-plugin/plugin.json")).toBeDefined();
expect(f.files.get("/sandbox/openrig/work/.openrig/startup/startup.md")?.toString()).toBe("Required filesystem startup, never conversation input");
expect(f.files.get(`/sandbox/openrig/${nativePath}`)).toEqual(history);
const descriptor = JSON.parse(f.files.get("/sandbox/openrig/control/launch.pending.json")!.toString());
expect(descriptor.resumeToken).toBe(first.resumeToken); expect(descriptor.plugins).toHaveLength(1);
}
return nativePrepare(binding, args);
});
// The host viewer is a rig process, not evidence of the guest's native conversation.
Object.assign(f.app.tmuxAdapter, {
listPanes: vi.fn(async () => [{ id: "%1", index: 0, cwd: f.root, width: 80, height: 24, active: true }]),
getPanePid: vi.fn(async () => 7001), getPaneCommand: vi.fn(async () => "rig"),
});
vi.spyOn(f.app.tmuxAdapter, "sendShellCommand").mockResolvedValue({ ok: true });
const textInput = vi.spyOn(f.app.tmuxAdapter, "sendText");
const adapter = new CommunityHarnessRuntimeAdapter("openshell-claude", { tmux: f.app.tmuxAdapter, environmentStartup: f.startup });
vi.spyOn(adapter, "checkReady").mockResolvedValue({ ready: true });
const result = await f.app.restoreOrchestrator.restore(snapshot.id, { adapters: { "openshell-claude": adapter }, fsOps: { exists: existsSync } });
const restoredNode = result.ok ? result.result.nodes.find(node => node.nodeId === f.node.id) : undefined;
if (["unchanged", "changed", "added", "removed"].includes(mode)) { expect(restoredNode?.status, JSON.stringify(result)).toBe("resumed"); expect(restored).toBe(true); }
else { expect(restored).toBe(false); expect(restoredNode?.status).not.toBe("resumed"); }
expect(textInput).not.toHaveBeenCalled();
});
@@ -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 -2
View File
@@ -1,8 +1,9 @@
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";
import { nodeClaudeMcpSchema } from "../../src/db/migrations/142_node_claude_mcp.js";
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
import { nodeCodexPermissionsSchema } from "../../src/db/migrations/137_node_codex_permissions.js";
import { claudeTokenReadingsSchema } from "../../src/db/migrations/134_claude_token_readings.js";
import { terminalPolicySchema } from "../../src/db/migrations/128_terminal_policy.js";
@@ -154,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, activityGrantsSchema];
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,79 @@
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";
import { OpenShellPodmanObserver } from "../src/adapters/openshell-podman-observer.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);
});
it("captures creator cleanup identities after compute is stopped", async () => {
const command = vi.fn(async () => JSON.stringify({id:"immutable",name:"worker-one",workspace:"workspace",phase:"Stopped"}));
const capture = vi.spyOn(OpenShellPodmanObserver.prototype,"capture").mockResolvedValue({version:1} as never);
cleanup.push(() => capture.mockRestore());
const binding = {guestRoot:"/sandbox/openrig",placement:{gateway:"gateway",workspace:"workspace",name:"worker-one"},resource:{id:"immutable",name:"worker-one",workspace:"workspace"}} as EnvironmentBinding;
expect(await new OpenShellDriver(command).captureCleanup(binding,"/owned/engine.sock")).toEqual({version:1});
expect(capture).toHaveBeenCalledWith(binding,"/owned/engine.sock");
});
it.each(["Pending", "Error"])("dispatches exact compute stop while live phase is %s", async phase => {
const command = vi.fn(async (args: string[]) => args.includes("get") ? JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase }) : "");
const binding = { placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding;
await new OpenShellDriver(command).stopCompute(binding, "selected-stop");
expect(command.mock.calls.filter(([args]) => args.includes("stop"))).toHaveLength(1);
});
it("rechecks dispatch permission after the driver's own identity await", async () => {
let enter!: () => void, release!: () => void, eligible = true;
const entered = new Promise<void>(resolve => { enter = resolve; }), gate = new Promise<void>(resolve => { release = resolve; });
const command = vi.fn(async (args: string[]) => { if (args.includes("get")) { enter(); await gate; return JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready" }); } return ""; });
const binding = { placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding;
const driver = new OpenShellDriver(command);
const attempt = Reflect.apply(driver.stopCompute, driver, [binding, "selected-stop", () => eligible]);
const refused = expect(attempt).rejects.toMatchObject({ code: "environment_selection_changed" });
await entered; eligible = false; release(); await refused;
expect(command.mock.calls.some(([args]) => args.includes("stop"))).toBe(false);
});
@@ -0,0 +1,60 @@
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");
});
it("the shipped resume latch refuses missing exact history before provider preparation", async () => {
const root=realpathSync(mkdtempSync(join(tmpdir(),"openshell-resume-"))); roots.push(root); mkdirSync(join(root,"control"));
const id="aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
writeFileSync(join(root,"control","launch.pending.json"),JSON.stringify({version:1,bindingId:"binding-one",generation:"generation-two",nativeId:id,resumeToken:id,nativeType:"community:openshell-claude",files:[],checkpoint:{nativeId:id,nativeType:"community:openshell-claude",files:[]}}));
expect(await command(root,"--prepare")).toMatchObject({ok:false,code:"environment_checkpoint_invalid"});
});
@@ -0,0 +1,40 @@
import { afterEach, expect, it } from "vitest";
import { createServer, type Server } from "node:net";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { OpenShellPodmanObserver } from "../src/adapters/openshell-podman-observer.js";
import type { EnvironmentBinding } from "../src/domain/environment-platform.js";
const closers: Array<() => unknown | Promise<unknown>> = [];
afterEach(async () => { for (const close of closers.splice(0).reverse()) await close(); });
const supported = process.platform === "linux" && process.getuid?.() !== 0;
async function fixture() {
const root = realpathSync(mkdtempSync(join(tmpdir(), "podman-observer-"))); closers.push(() => rmSync(root, { recursive: true, force: true }));
const socket = join(root, "engine.sock"), server: Server = createServer(); await new Promise<void>(resolve => server.listen(socket, resolve));
closers.push(() => new Promise<void>(resolve => server.close(() => resolve())));
const id = "compute-one", workloadId = "a".repeat(64), supervisorId = "b".repeat(64);
let rows: Record<string, any>[] = [{ Id: workloadId, Names: ["workload"], Labels: { "openshell.managed": "true", "openshell.ai/sandbox-id": id, "openshell.ai/isolation-role": "sandbox", "openshell.ai/sandbox-workspace": "workspace" } },
{ Id: supervisorId, Names: [`openshell-supervisor-${id}`], Labels: {} }];
let volumeStatus = 404;
const paths: string[] = [];
const observer = new OpenShellPodmanObserver(async (_socket, path) => {
paths.push(path);
if (path.startsWith("/libpod/containers/json")) return { status: 200, body: rows };
if (path.startsWith("/libpod/containers/")) return { status: 200, body: { Id: path.includes(workloadId) ? workloadId : supervisorId,
Mounts: [`openshell-sandbox-${id}-workspace`, `openshell-control-${id}`, `openshell-channel-${id}`].map(Name => ({ Type: "volume", Name })) } };
return { status: volumeStatus, body: {} };
});
const binding = { resource: { id }, placement: { workspace: "workspace" } } as EnvironmentBinding;
return { observer, binding, socket, paths, disappear: () => { rows = [{ Id: "c".repeat(64), Names: ["unrelated"], Labels: {} }]; }, failVolume: () => { volumeStatus = 500; } };
}
it.skipIf(!supported)("captures exact local workload/supervisor/volumes then ignores unrelated engine resources", async () => {
const f = await fixture(), selected = await f.observer.capture(f.binding, f.socket);
expect(selected).toMatchObject({ resourceId: "compute-one", workloadId: "a".repeat(64), supervisorId: "b".repeat(64) });
f.disappear(); expect(await f.observer.observe(f.binding, selected)).toEqual({ workload: false, supervisor: false, volumes: false });
expect(f.paths.every(path => path.startsWith("/libpod/containers/") || path.startsWith("/libpod/volumes/"))).toBe(true);
});
it.skipIf(!supported)("does not treat an engine error or missing original inventory as cleanup", async () => {
const f = await fixture(), selected = await f.observer.capture(f.binding, f.socket); f.disappear(); f.failVolume();
await expect(f.observer.observe(f.binding, selected)).rejects.toMatchObject({ code: "environment_cleanup_unconfirmed" });
await expect(f.observer.observe(f.binding)).rejects.toMatchObject({ code: "environment_cleanup_unconfirmed" });
});
@@ -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);
});
});
@@ -241,6 +241,16 @@ describe("SeatLifecycleService", () => {
expect(events[0]).toMatchObject({ nodeId: a.node.id, sessionName: a.sessionName, reason: "single-seat stop test", operator: "op@rig" });
});
it("labels a managed community stop as pane-only while retaining its normal seat audit", async () => {
const a = seatFixture("community-stop", "dev.worker");
db.prepare("UPDATE nodes SET runtime='openshell-claude' WHERE id=?").run(a.node.id);
sessionRegistry.recordCommunityHarnessSelection(a.session.id, "openshell-claude");
const result = await service.stopSeat({ seatRef: a.sessionName, reason: "close selected viewer" });
expect(result).toMatchObject({ ok: true, paneOnly: true });
expect(tmux.killed).toEqual([a.sessionName]);
expect(eventsOfType(db, "session.stopped")).toHaveLength(1);
});
describe("intentional stop in the rig lifecycle", () => {
function summary(rigId: string) {
return new PsProjectionService({ db }).getEntries().find((entry) => entry.rigId === rigId)!;
@@ -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();