mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f7bc1b775 | ||
|
|
fb85b67911 | ||
|
|
de3549274c | ||
|
|
248633703f | ||
|
|
cb8f5637c7 | ||
|
|
7da94b6985 | ||
|
|
b7a1234324 | ||
|
|
bdcd0992ea | ||
|
|
5cb44e3a5e | ||
|
|
3eac761bc8 | ||
|
|
f8e062fe15 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# OpenShell reference driver
|
||||
|
||||
Run `rig environment setup --help` to install the maintained OpenShell 0.1.6 / Claude Code reference from the packaged daemon assets. Its README, image recipe, endpoint-specific policy, example RigSpec/AgentSpecs and versioned driver schema are installed together. Setup prepares files and an ordinary scoped work grant; it does not create compute or launch a provider.
|
||||
|
||||
This first cut supports explicit owned create or exact attached binding, required destination projection and verified JSON artifact return through authenticated MCP work tools. Native stop, compute deletion, checkpoint and exact native restore remain unsupported. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
|
||||
@@ -122,6 +122,17 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
- **The generation attributes a post; it isn't authentication.**
|
||||
- A post carrying an earlier occupant's generation never counts for the live seat.
|
||||
- The hook token is one local token, so any process holding it can post for any seat.
|
||||
- **A seat activity grant** is what a runner in another environment, such as a sandbox guest, receives instead of the
|
||||
local token. It arrives in the same `OPENRIG_ACTIVITY_HOOK_TOKEN`, with an `OPENRIG_URL` the guest can reach, and it
|
||||
speaks only for its own seat and launch generation:
|
||||
- with a grant, the generation is authenticated, and you may leave out `nodeId` and `generation`;
|
||||
- a post for another seat is refused with `403 activity_grant_wrong_seat`;
|
||||
- a post claiming another generation, or from an occupant that has since been replaced, is refused with
|
||||
`409 activity_grant_stale`;
|
||||
- a post before the launch has registered its occupant is refused with `409 activity_grant_pending`;
|
||||
- a post with a revoked grant is refused with `401 activity_grant_revoked`.
|
||||
|
||||
A refused post records nothing, so the seat's activity stays as it was, or unknown.
|
||||
|
||||
## 5. Input: one submitted message is one prompt
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import { Command } from "commander";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonPath, getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addGrant, grantFor, mcpTokenFilePath, readGrants } from "../mcp-http.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export interface EnvironmentCommandDeps extends StatusDeps { home?: string; assets?: string }
|
||||
interface WorkConnection { version: 1; mcpUrl: string; seat: string; rig: string; grantName: string; artifactRoot: string; token: string }
|
||||
const managedId = (id: string): string => {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(id)) throw new Error("A short binding identifier is required");
|
||||
return id;
|
||||
};
|
||||
function preserveWrite(file: string, data: string | Buffer): void {
|
||||
mkdirSync(resolve(file, ".."), { recursive: true, mode: 0o700 });
|
||||
if (existsSync(file) || (() => { try { return lstatSync(file).isSymbolicLink(); } catch { return false; } })()) {
|
||||
if (lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile() || !readFileSync(file).equals(Buffer.from(data)))
|
||||
throw new Error(`An existing file is preserved: ${file}`);
|
||||
return;
|
||||
}
|
||||
writeFileSync(file, data, { flag: "wx", mode: 0o600 });
|
||||
}
|
||||
function connectionRef(home: string, bindingId: string): string { return join(home, "environment-secrets", managedId(bindingId), "work.json"); }
|
||||
function readConnection(file: string): WorkConnection {
|
||||
const stat = lstatSync(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384 || (stat.mode & 0o077)) throw new Error("The managed work connection must be a private regular file");
|
||||
const value = JSON.parse(readFileSync(file, "utf8")) as WorkConnection;
|
||||
if (value.version !== 1 || typeof value.token !== "string" || !value.token || typeof value.mcpUrl !== "string") throw new Error("The work connection is invalid");
|
||||
return value;
|
||||
}
|
||||
async function prepareWork(home: string, input: { bindingId: string; seat: string; rig: string; mcpUrl: string; artifactRoot: string }): Promise<string> {
|
||||
const url = new URL(input.mcpUrl);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || !url.pathname.endsWith("/mcp"))
|
||||
throw new Error("The work endpoint must be an HTTP(S) MCP URL without embedded credentials");
|
||||
if (!/^[^\s@]+@[^\s@]+$/.test(input.seat) || !input.rig) throw new Error("A canonical seat and its rig are required");
|
||||
const file = connectionRef(home, input.bindingId), grantName = `environment-${input.bindingId}`;
|
||||
if (existsSync(file)) {
|
||||
const prior = readConnection(file), grants = readGrants(mcpTokenFilePath(home)) ?? [];
|
||||
const grant = grantFor(grants, prior.token);
|
||||
if (!grant || grant.name !== grantName || prior.seat !== input.seat || prior.rig !== input.rig || prior.mcpUrl !== url.toString()
|
||||
|| prior.artifactRoot !== resolve(input.artifactRoot) || grant.seat !== input.seat || grant.owner || grant.workspace
|
||||
|| grant.rigs.length !== 1 || grant.rigs[0] !== input.rig || grant.roots.length !== 0) throw new Error("The existing managed work connection changed or was revoked; preserve it and reconcile before setup");
|
||||
return file;
|
||||
}
|
||||
const token = await addGrant(mcpTokenFilePath(home), { name: grantName, seat: input.seat, rigs: [input.rig], roots: [], workspace: false, owner: false });
|
||||
const data: WorkConnection = { version: 1, mcpUrl: url.toString(), seat: input.seat, rig: input.rig, grantName, artifactRoot: resolve(input.artifactRoot), token };
|
||||
preserveWrite(file, JSON.stringify(data) + "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
export function environmentCommand(override?: EnvironmentCommandDeps): Command {
|
||||
const cmd = new Command("environment").description("Explicit OpenShell placement, validated projection and actual artifact return (driver protocol 1)");
|
||||
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-reachable activity daemon origin; 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 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").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("effect").argument("<binding>").requiredOption("--effect <name>").option("--json")
|
||||
.description("Inspect the selected lifecycle effect; driver version 1 reports unsupported and preserves attached resources")
|
||||
.action(action(async (id: string, opts: { effect: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect })))));
|
||||
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. Shutdown and exact restore are not implemented here.\n");
|
||||
return cmd;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -523,6 +523,7 @@ export const MCP_OPERATIONS: ReadonlyArray<{ tool: string; routes: readonly stri
|
||||
{ tool: "rig_queue_list", routes: ["GET /api/queue/list"], effect: "read", access: "work" },
|
||||
{ tool: "rig_queue_claim", routes: ["POST /api/queue/:qitemId/claim"], effect: "destructive", access: "work" },
|
||||
{ tool: "rig_queue_update", routes: ["POST /api/queue/:qitemId/update"], effect: "destructive", access: "work" },
|
||||
{ tool: "rig_environment_export", routes: ["GET /api/queue/:qitemId", "POST /api/environments/:bindingId/export"], effect: "additive", access: "work" },
|
||||
{ tool: "rig_telemetry_events", routes: ["GET /api/telemetry/v1/events"], effect: "read", access: "owner" },
|
||||
{ tool: "rig_whoami", routes: ["GET /api/whoami"], effect: "read", access: "work" },
|
||||
{ tool: "rig_team", routes: ["GET /api/rigs/:rigId", "GET /api/rigs/:rigId/nodes", "GET /healthz"], effect: "read", access: "work" },
|
||||
@@ -1384,6 +1385,28 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
},
|
||||
);
|
||||
|
||||
// Artifact publication uses this actual authenticated work caller, never a body actor or activity token.
|
||||
if (offered("rig_environment_export")) server.registerTool(
|
||||
"rig_environment_export",
|
||||
{
|
||||
description: "Transfer a bound worker's actual JSON artifact to the host, verify its bytes/schema/context and append a durable receipt to its existing work. Does not close work, stop compute or restore native history. Requires an authenticated HTTP work connection.",
|
||||
inputSchema: z.object({
|
||||
bindingId: z.string(), operationId: z.string(), taskId: z.string(), generation: z.string(), path: z.string(),
|
||||
sha256: z.string(), bytes: z.number().int().positive().max(1048576),
|
||||
schema: z.object({ format: z.literal("json-object/v1"), fields: z.record(z.string(), z.enum(["string", "number", "boolean"])), required: z.array(z.string()) }),
|
||||
context: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
||||
}),
|
||||
annotations: ADDITIVE,
|
||||
},
|
||||
async ({ bindingId, ...input }) => {
|
||||
if (!access) return { content: [{ type: "text" as const, text: "This export requires a validated HTTP work grant; a declared stdio/local identity is not authentication." }], isError: true };
|
||||
try {
|
||||
const denied = await itemOutOfScope(input.taskId, "owner"); if (denied) return denied;
|
||||
return mapResult(await client.post(`/api/environments/${encodeURIComponent(bindingId)}/export`, input), undefined, { structured: true });
|
||||
} catch (error) { return caught(error); }
|
||||
},
|
||||
);
|
||||
|
||||
// 24. rig_telemetry_events — one bounded page of event metadata, the page `rig telemetry events` reads
|
||||
if (offered("rig_telemetry_events")) server.registerTool(
|
||||
"rig_telemetry_events",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { createFullTestDb, 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 { 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 = createFullTestDb(); 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 fixture = createTestApp(db, { appDeps: { 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:7433",
|
||||
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).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);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
# Supply a digest-pinned base with Python3 and Node22+, and a trusted Claude Code 2.1.310 binary.
|
||||
# No credentials, histories or work payloads belong in this build context.
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
USER 0
|
||||
COPY --chmod=0755 claude /usr/local/bin/claude
|
||||
COPY --chmod=0644 guest.py /opt/openrig/guest.py
|
||||
RUN python3 --version && node --version && /usr/local/bin/claude --version \
|
||||
&& mkdir -p /sandbox/openrig/control /sandbox/openrig/work/artifacts /sandbox/openrig/native \
|
||||
&& chown -R 1000:1000 /sandbox/openrig
|
||||
USER 1000:1000
|
||||
WORKDIR /sandbox/openrig/work
|
||||
ENV CLAUDE_CONFIG_DIR=/sandbox/openrig/native
|
||||
ENV DISABLE_AUTOUPDATER=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
ENTRYPOINT ["python3", "/opt/openrig/guest.py"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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:7433 --json
|
||||
```
|
||||
|
||||
Use the actual guest-reachable MCP and daemon ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. OpenShell's REST policy applies to a host and port; authentication and authorization are enforced by the MCP member grant and separate seat/generation activity grant, not by a path-filter claim. 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:7433 --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. 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.
|
||||
|
||||
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 host checks path/type/size/hash/schema/context, publishes to a fresh no-overwrite path, and atomically appends a receipt to the existing task. Publication before a lost receipt is reconciled from verified host bytes even if the guest is gone. The task remains open until its owner reports completion. The receipt records the authenticated grant-derived caller; a declared body actor and the activity token cannot impersonate a work principal. Preserve the same operation on retry. Changed host bytes, generation, task owner or request refuse publication/receipt rather than overwriting or fabricating completion.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Driver protocol 1 is documented by `driver-v1.schema.json` and the daemon's `EnvironmentDriver` interface. Core owns placement provenance, operation intent, resolved-file selection and receipts; the driver owns fixed OpenShell CLI transport and guest path mapping. There is no generic plugin loader or alternative task store. Projection is bounded to 4096 files, 1 MiB per file and 32 MiB total. Unsupported runtime resources/subagents refuse the selected launch explicitly; ordinary host runtimes keep their existing behavior.
|
||||
|
||||
Native stop, compute stop/delete, checkpoint and exact native restore are not implemented by this driver version. Effect requests return unsupported; closing a viewer proves only detachment. Do not use it as a shutdown receipt. A previous requested or confirmed native execution blocks a fresh launch until lifecycle/restore can reconcile it. No native, provider, lifecycle or restore conformance is claimed by source tests; qualify the maintained reference separately on its exact build.
|
||||
@@ -0,0 +1,20 @@
|
||||
name: openshell-coordinator
|
||||
version: "1.0"
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
guidance: []
|
||||
plugins: [openrig-core]
|
||||
resources:
|
||||
plugins:
|
||||
- id: openrig-core
|
||||
source:
|
||||
kind: local
|
||||
path: openrig-home:plugins/openrig-core
|
||||
startup:
|
||||
files:
|
||||
- path: role.md
|
||||
orientation: role
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
actions: []
|
||||
@@ -0,0 +1 @@
|
||||
Coordinate useful work with the OpenShell worker through the existing OpenRig queue. Give each job a concrete JSON artifact contract including its task id, revision, required fields and relative artifact path. Read the verified host artifact before accepting completion. An intent, native process or posted summary alone does not establish returned bytes. Do not stop or delete an attached environment.
|
||||
@@ -0,0 +1,20 @@
|
||||
name: openshell-worker
|
||||
version: "1.0"
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
guidance: []
|
||||
plugins: [openrig-core]
|
||||
resources:
|
||||
plugins:
|
||||
- id: openrig-core
|
||||
source:
|
||||
kind: local
|
||||
path: openrig-home:plugins/openrig-core
|
||||
startup:
|
||||
files:
|
||||
- path: role.md
|
||||
orientation: role
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
actions: []
|
||||
@@ -0,0 +1,3 @@
|
||||
You are the team's contained worker. Use the authenticated OpenRig MCP tools for identity, team and queue work. Read CLAUDE.md and the current files it lists before acting. Host CLI paths are not guest paths.
|
||||
|
||||
Claim assigned work. Produce a JSON object under /sandbox/openrig/work/artifacts. Read your binding id from OPENRIG_ENVIRONMENT_BINDING and generation from OPENRIG_OCCUPANT_GENERATION. Compute its SHA256 and byte count, then call rig_environment_export with the binding id, task id, occupant generation, stable operation id, relative path, expected hash/bytes, json-object/v1 schema and expected task/revision context. Read the returned verified artifact receipt. Only then report completion through the ordinary queue; an export does not close your work. If export is unconfirmed, retain the same operation and reconcile it rather than inventing a receipt.
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Example provider profile. OpenShell does not load it; import it explicitly:
|
||||
# openshell provider profile lint -f providers/claude-code.yaml
|
||||
# openshell provider profile import -f providers/claude-code.yaml --global
|
||||
#
|
||||
# Copy and edit this file rather than importing it unchanged. `binaries` is the
|
||||
# least-privilege control that decides which processes may reach the endpoints
|
||||
# below, so it has to name the paths in *your* image.
|
||||
#
|
||||
# Client binaries: claude.
|
||||
# Reference layout: the Claude Code CLI at /usr/bin/claude or
|
||||
# /usr/local/bin/claude. An npm global install resolves
|
||||
# elsewhere (for example /usr/lib/node_modules/@anthropic-ai/**);
|
||||
# add that path if your image installs it that way.
|
||||
# Credential scope: ANTHROPIC_API_KEY or CLAUDE_API_KEY, sent as the x-api-key
|
||||
# header to the endpoints below and nowhere else.
|
||||
# Endpoint access: api.anthropic.com for inference; statsig.anthropic.com and
|
||||
# sentry.io for the CLI's telemetry and error reporting. Drop
|
||||
# the last two if your policy forbids that traffic.
|
||||
# Smoke test: openshell sandbox create --provider <name> -- \
|
||||
# claude -p 'reply with OK'
|
||||
|
||||
id: claude-code
|
||||
display_name: Claude Code
|
||||
description: Claude Code CLI
|
||||
category: agent
|
||||
inference_capable: true
|
||||
credentials:
|
||||
- name: api_key
|
||||
description: Anthropic API key used by Claude Code
|
||||
env_vars: [ANTHROPIC_API_KEY, CLAUDE_API_KEY]
|
||||
required: true
|
||||
auth_style: header
|
||||
header_name: x-api-key
|
||||
discovery:
|
||||
credentials: [api_key]
|
||||
endpoints:
|
||||
- host: api.anthropic.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
access: read-write
|
||||
enforcement: enforce
|
||||
- host: statsig.anthropic.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
access: read-write
|
||||
enforcement: enforce
|
||||
- host: sentry.io
|
||||
port: 443
|
||||
protocol: rest
|
||||
access: read-write
|
||||
enforcement: enforce
|
||||
binaries: [/usr/bin/claude, /usr/local/bin/claude]
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://www.openrig.dev/contracts/environment-driver-v1",
|
||||
"title": "OpenRig environment driver version 1 binding",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"bindingId",
|
||||
"seat",
|
||||
"baseRuntime",
|
||||
"guestRoot",
|
||||
"guestBaseUrl",
|
||||
"placement"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"bindingId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$"
|
||||
},
|
||||
"nodeId": {
|
||||
"type": "string"
|
||||
},
|
||||
"seat": {
|
||||
"type": "string"
|
||||
},
|
||||
"baseRuntime": {
|
||||
"const": "claude-code"
|
||||
},
|
||||
"guestRoot": {
|
||||
"const": "/sandbox/openrig"
|
||||
},
|
||||
"guestBaseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"coordinationRef": {
|
||||
"type": "string"
|
||||
},
|
||||
"artifactRoot": {
|
||||
"type": "string"
|
||||
},
|
||||
"placement": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"gateway",
|
||||
"workspace",
|
||||
"name",
|
||||
"image",
|
||||
"provider",
|
||||
"policy"
|
||||
],
|
||||
"properties": {
|
||||
"gateway": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"policySha256": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/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 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)
|
||||
if not path.exists():
|
||||
raise Refused("environment_file_missing")
|
||||
if not path.is_file() or path.stat().st_size > limit:
|
||||
raise Refused("environment_file_invalid")
|
||||
data = path.read_bytes()
|
||||
if len(data) > limit:
|
||||
raise Refused("environment_file_invalid")
|
||||
return data
|
||||
|
||||
|
||||
def atomic(root, raw, data):
|
||||
path = safe(root, raw)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
safe(root, path)
|
||||
temp = path.parent / (".openrig-" + uuid.uuid4().hex)
|
||||
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
if temp.exists():
|
||||
temp.unlink()
|
||||
|
||||
|
||||
def descriptor(root, pending=False):
|
||||
name = "control/launch.pending.json" if pending else "control/launch.json"
|
||||
value = json.loads(regular(root, name))
|
||||
if value.get("version") != 1 or not value.get("generation") or not value.get("bindingId"):
|
||||
raise Refused("environment_descriptor_invalid")
|
||||
for file in value["files"]:
|
||||
data = regular(root, file["path"])
|
||||
if len(data) != file["bytes"] or digest(data) != file["sha256"]:
|
||||
raise Refused("environment_projection_changed")
|
||||
# P1-P3 never silently replace a requested native conversation with a fresh one.
|
||||
if value.get("resumeToken"):
|
||||
raise Refused("environment_native_restore_unsupported")
|
||||
if not re.fullmatch(r"[0-9a-f-]{36}", value["nativeId"]):
|
||||
raise Refused("environment_native_identity_invalid")
|
||||
return value
|
||||
|
||||
|
||||
def launch(root):
|
||||
deadline = time.monotonic() + 600
|
||||
while not safe(root, "control/launch.json").exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise Refused("environment_preparation_expired")
|
||||
time.sleep(.2)
|
||||
value = descriptor(root)
|
||||
fd = os.open(safe(root, "control/native.lock"), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
os.set_inheritable(fd, True)
|
||||
env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(Path(root) / "native"),
|
||||
"CLAUDE_CONFIG_DIR": str(Path(root) / "native")}
|
||||
secret = json.loads(regular(root, "control/activity.json"))
|
||||
allowed = {"OPENRIG_URL", "OPENRIG_ACTIVITY_HOOK_TOKEN", "OPENRIG_NODE_ID", "OPENRIG_SESSION_NAME", "OPENRIG_OCCUPANT_GENERATION"}
|
||||
if set(secret) != allowed or secret["OPENRIG_OCCUPANT_GENERATION"] != value["generation"]:
|
||||
raise Refused("environment_activity_projection_invalid")
|
||||
env.update(secret)
|
||||
env.update({"DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"OPENRIG_RUNTIME": "openshell-claude", "OPENRIG_NODE": "/usr/bin/node",
|
||||
"OPENRIG_ENVIRONMENT_BINDING": value["bindingId"]})
|
||||
if safe(root, "control/provider-api-ref").exists():
|
||||
atomic(root, "native/settings.json", json.dumps({"apiKeyHelper": "cat /sandbox/openrig/control/provider-api-ref"}).encode())
|
||||
# Provider-owned injection may supply these native endpoint references. No host environment is copied.
|
||||
for name in ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
|
||||
if name in os.environ:
|
||||
env[name] = os.environ[name]
|
||||
argv = ["/usr/local/bin/claude", "--session-id", value["nativeId"], "--strict-mcp-config", "--mcp-config", str(safe(root, "control/coordination.json"))]
|
||||
for plugin in value.get("plugins", []):
|
||||
argv.extend(["--plugin-dir", str(safe(root, plugin))])
|
||||
if value.get("model"):
|
||||
argv.extend(["--model", value["model"]])
|
||||
if value.get("effort"):
|
||||
argv.extend(["--effort", value["effort"]])
|
||||
if value.get("permissionMode"):
|
||||
argv.extend(["--permission-mode", value["permissionMode"]])
|
||||
work = safe(root, "work")
|
||||
work.mkdir(exist_ok=True)
|
||||
os.chdir(work)
|
||||
atomic(root, "control/native.json", json.dumps({"nativeId": value["nativeId"], "generation": value["generation"], "pid": os.getpid()}).encode())
|
||||
os.execve(argv[0], argv, env)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--root", default=DEFAULT_ROOT)
|
||||
for option in ("read", "stat", "stage", "install"):
|
||||
p.add_argument("--" + option)
|
||||
p.add_argument("--destination")
|
||||
p.add_argument("--previous")
|
||||
p.add_argument("--mode", choices=("0600", "0700"), default="0600")
|
||||
p.add_argument("--prepare", action="store_true")
|
||||
p.add_argument("--identity", action="store_true")
|
||||
args = p.parse_args()
|
||||
root = Path(args.root)
|
||||
if not root.is_absolute() or any(part.is_symlink() for part in (root, *root.parents)):
|
||||
raise Refused("environment_root_invalid")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if args.stage:
|
||||
path = safe(root, args.stage)
|
||||
if path.exists():
|
||||
raise Refused("environment_stage_exists")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return {"ok": True}
|
||||
if args.read or args.stat:
|
||||
data = regular(root, args.read or args.stat)
|
||||
return {"ok": True, "sha256": digest(data), "bytes": len(data),
|
||||
**({"base64": base64.b64encode(data).decode()} if args.read else {})}
|
||||
if args.install:
|
||||
data = regular(root, args.install)
|
||||
destination = safe(root, args.destination)
|
||||
if destination.exists():
|
||||
if args.previous == "missing" or digest(regular(root, destination)) != args.previous:
|
||||
raise Refused("environment_projection_conflict")
|
||||
elif args.previous != "missing":
|
||||
raise Refused("environment_projection_changed")
|
||||
atomic(root, destination, data)
|
||||
os.chmod(destination, int(args.mode, 8))
|
||||
safe(root, args.install).unlink()
|
||||
return {"ok": True, "sha256": digest(data), "bytes": len(data)}
|
||||
if args.prepare:
|
||||
value = descriptor(root, pending=True)
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
published = safe(root, "control/launch.json")
|
||||
if published.exists():
|
||||
if regular(root, published) != encoded:
|
||||
raise Refused("environment_native_already_selected")
|
||||
return {"ok": True, "generation": value["generation"], "nativeId": value["nativeId"]}
|
||||
# This is the guest provider's injected reference, never an owner-host environment value.
|
||||
reference = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
|
||||
if not reference:
|
||||
raise Refused("environment_provider_reference_missing")
|
||||
atomic(root, "control/provider-api-ref", reference.encode())
|
||||
Path(root, "native").mkdir(exist_ok=True)
|
||||
atomic(root, "control/launch.json", encoded)
|
||||
return {"ok": True, "generation": value["generation"], "nativeId": value["nativeId"]}
|
||||
if args.identity:
|
||||
value = json.loads(regular(root, "control/native.json"))
|
||||
fields = (Path("/proc") / str(value["pid"]) / "cmdline").read_bytes().split(b"\0")
|
||||
if value["nativeId"].encode() not in fields or not any(b"claude" in field for field in fields):
|
||||
raise Refused("environment_native_unconfirmed")
|
||||
return {"ok": True, **value}
|
||||
launch(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
result = main()
|
||||
if result is not None:
|
||||
print(json.dumps(result))
|
||||
except (Refused, ValueError, KeyError, OSError) as error:
|
||||
print(json.dumps({"ok": False, "code": str(error) if isinstance(error, Refused) else "environment_guest_unconfirmed"}))
|
||||
@@ -0,0 +1,33 @@
|
||||
version: 1
|
||||
filesystem_policy:
|
||||
include_workdir: true
|
||||
read_only: [/usr, /lib, /lib64, /etc, /opt/openrig, /proc, /sys]
|
||||
read_write: [/sandbox/openrig, /tmp]
|
||||
network_policies:
|
||||
openrig_mcp:
|
||||
endpoints:
|
||||
- host: host.openshell.internal
|
||||
port: 7781
|
||||
protocol: rest
|
||||
access: read-write
|
||||
enforcement: enforce
|
||||
binaries:
|
||||
- path: /usr/local/bin/claude
|
||||
openrig_activity:
|
||||
endpoints:
|
||||
- host: host.openshell.internal
|
||||
port: 7433
|
||||
protocol: rest
|
||||
access: read-write
|
||||
enforcement: enforce
|
||||
binaries:
|
||||
- path: /usr/bin/node
|
||||
claude_startup_connectivity:
|
||||
endpoints:
|
||||
- host: platform.claude.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
access: read-only
|
||||
enforcement: enforce
|
||||
binaries:
|
||||
- path: /usr/local/bin/claude
|
||||
@@ -0,0 +1,10 @@
|
||||
format: 1
|
||||
name: openshell-claude
|
||||
command: rig environment connect
|
||||
maintainer: OpenRig contributors
|
||||
limits:
|
||||
- Requires explicit environment create or attach before rig up; driver protocol 1, OpenShell 0.1.6 and Claude Code 2.1.310.
|
||||
- Projects required context and verifies destination bytes; native ingestion and provider readiness need separate observation.
|
||||
- Closing the viewer does not stop native execution or compute.
|
||||
- Native stop, compute deletion, checkpoint and restore are unsupported in this first cut.
|
||||
passes_model: true
|
||||
@@ -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,27 @@ 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 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 +76,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,78 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, posix } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EnvironmentError, regularBytes, sha256, type EnvironmentBinding, type EnvironmentDriver, type EnvironmentResource, type Placement } from "../domain/environment-platform.js";
|
||||
|
||||
const runFile = promisify(execFile);
|
||||
export type OpenShellCommand = (args: string[]) => Promise<string>;
|
||||
/** Maintained OpenShell 0.1.6 CLI driver. No shell, dynamic plugin loader or credential copying. */
|
||||
export class OpenShellDriver implements EnvironmentDriver {
|
||||
readonly version = 1 as const;
|
||||
constructor(private readonly command: OpenShellCommand = async args => {
|
||||
try { return (await runFile("openshell", args, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 })).stdout; }
|
||||
catch { throw new EnvironmentError("openshell_transport_unconfirmed", "OpenShell did not return a confirmed result; reconcile the saved operation", 503); }
|
||||
}) {}
|
||||
private prefix(place: Placement): string[] { return ["--gateway", place.gateway, "--workspace", place.workspace, "sandbox"]; }
|
||||
private resource(value: unknown): EnvironmentResource {
|
||||
const row = value as Record<string, any>;
|
||||
if (!row || typeof row.id !== "string" || !row.id || typeof row.name !== "string" || typeof row.workspace !== "string"
|
||||
|| typeof row.phase !== "string") throw new EnvironmentError("openshell_response_invalid", "OpenShell returned an incomplete resource identity");
|
||||
return { id: row.id, name: row.name, workspace: row.workspace, phase: row.phase,
|
||||
...(typeof row.host_key_fingerprint === "string" ? { fingerprint: row.host_key_fingerprint } : {}),
|
||||
...(typeof row.labels?.openrigoperation === "string" ? { operationId: row.labels.openrigoperation } : {}) };
|
||||
}
|
||||
async list(place: Placement): Promise<{ complete: boolean; resources: EnvironmentResource[] }> {
|
||||
const page = JSON.parse(await this.command([...this.prefix(place), "list", "--output", "json", "--page-size", "100"]));
|
||||
if (!page || !Array.isArray(page.sandboxes) || page.sandboxes.length > 100 || typeof page.next_page_token !== "string")
|
||||
throw new EnvironmentError("openshell_listing_invalid", "OpenShell returned an invalid resource listing");
|
||||
return { complete: page.next_page_token === "", resources: page.sandboxes.map((row: unknown) => this.resource(row)) };
|
||||
}
|
||||
async create(input: { operationId: string; placement: Placement }): Promise<EnvironmentResource> {
|
||||
const p = input.placement, bytes = regularBytes(p.policy);
|
||||
if (sha256(bytes) !== p.policySha256) throw new EnvironmentError("environment_policy_changed", "The selected provider policy changed before create");
|
||||
const stage = mkdtempSync(join(tmpdir(), "openrig-policy-"));
|
||||
const policy = join(stage, "policy.yaml"); writeFileSync(policy, bytes, { mode: 0o600 });
|
||||
try { return this.resource(JSON.parse(await this.command([...this.prefix(p), "create", "--name", p.name, "--from", p.image,
|
||||
"--provider", p.provider, "--no-auto-providers", "--cpu", "1", "--memory", "2Gi", "--policy", policy,
|
||||
"--label", `openrigoperation=${input.operationId}`, "--tty", "--detach", "--restart-policy", "never", "--output", "json",
|
||||
"--", "python3", "/opt/openrig/guest.py"]))); } finally { rmSync(stage, { recursive: true }); }
|
||||
}
|
||||
async inspect(binding: EnvironmentBinding): Promise<EnvironmentResource> {
|
||||
return this.resource(JSON.parse(await this.command([...this.prefix(binding.placement), "get", binding.placement.name, "--output", "json"])));
|
||||
}
|
||||
private async checked(binding: EnvironmentBinding): Promise<void> {
|
||||
const actual = await this.inspect(binding), saved = binding.resource;
|
||||
if (!saved || actual.id !== saved.id || actual.name !== saved.name || actual.workspace !== saved.workspace
|
||||
|| saved.fingerprint && actual.fingerprint !== saved.fingerprint) throw new EnvironmentError("environment_identity_changed", "The live OpenShell resource does not match the saved binding");
|
||||
if (actual.phase !== "Ready") throw new EnvironmentError("environment_not_ready", "The bound OpenShell resource is not Ready");
|
||||
}
|
||||
async guest(binding: EnvironmentBinding, args: string[]): Promise<Record<string, any>> {
|
||||
await this.checked(binding);
|
||||
const result = JSON.parse(await this.command([...this.prefix(binding.placement), "exec", binding.placement.name,
|
||||
"--no-login-shell", "--timeout", "20", "--", "python3", "/opt/openrig/guest.py", "--root", binding.guestRoot, ...args]));
|
||||
if (!result || result.ok !== true) throw new EnvironmentError(result?.code ?? "environment_guest_refused", "The guest operation was not confirmed");
|
||||
return result;
|
||||
}
|
||||
async upload(binding: EnvironmentBinding, source: string, destination: string, previousHash?: string, executable = false): Promise<void> {
|
||||
const transfer = randomUUID();
|
||||
const stage = posix.join(binding.guestRoot, "control", `transfer-${transfer}`);
|
||||
await this.guest(binding, ["--stage", stage]);
|
||||
await this.checked(binding);
|
||||
await this.command([...this.prefix(binding.placement), "upload", binding.placement.name, source, stage, "--no-git-ignore"]);
|
||||
await this.guest(binding, ["--install", stage, "--destination", destination, "--previous", previousHash ?? "missing", "--mode", executable ? "0700" : "0600"]);
|
||||
}
|
||||
async read(binding: EnvironmentBinding, path: string): Promise<Buffer> {
|
||||
const result = await this.guest(binding, ["--read", path]);
|
||||
if (typeof result.base64 !== "string") throw new EnvironmentError("openshell_response_invalid", "Guest file bytes are unavailable");
|
||||
return Buffer.from(result.base64, "base64");
|
||||
}
|
||||
async download(binding: EnvironmentBinding, source: string, destination: string): Promise<void> {
|
||||
await this.guest(binding, ["--stat", source]);
|
||||
await this.checked(binding);
|
||||
await this.command([...this.prefix(binding.placement), "download", binding.placement.name, source, destination]);
|
||||
}
|
||||
connectArgs(binding: EnvironmentBinding): string[] { return [...this.prefix(binding.placement), "connect", binding.placement.name]; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodeAccountHomeSchema } from "./migrations/141_node_account_home.js";
|
||||
import { escalationTargetRefsSchema } from "./migrations/126_escalation_target_refs.js";
|
||||
import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_readings.js";
|
||||
@@ -287,4 +289,6 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
sessionAgentRunnerSchema,
|
||||
nodeAccountHomeSchema,
|
||||
nodeClaudeMcpSchema,
|
||||
activityGrantsSchema,
|
||||
environmentBindingsSchema,
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* A seat activity grant: one seat's (node's) authority to post activity for one launch generation, for a workload that
|
||||
* can't hold the install-wide hook token, such as a guest in another environment. Only the token's sha256 is stored. One
|
||||
* active grant per node; a revoked grant keeps its row so a later post is refused as revoked, not unknown.
|
||||
*/
|
||||
export const activityGrantsSchema: Migration = {
|
||||
name: "143_activity_grants.sql",
|
||||
sql: `
|
||||
CREATE TABLE activity_grants (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
generation TEXT NOT NULL,
|
||||
token_sha256 TEXT NOT NULL UNIQUE,
|
||||
issued_by TEXT NOT NULL,
|
||||
issued_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
revoke_reason TEXT
|
||||
);
|
||||
CREATE INDEX idx_activity_grants_active ON activity_grants(node_id) WHERE revoked_at IS NULL;
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Placement provenance and effect receipts, never a second work queue or secret store. */
|
||||
export const environmentBindingsSchema: Migration = {
|
||||
name: "144_environment_bindings.sql",
|
||||
sql: `
|
||||
CREATE TABLE environment_bindings (
|
||||
binding_id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL CHECK(version = 1),
|
||||
node_id TEXT REFERENCES nodes(id),
|
||||
seat TEXT NOT NULL,
|
||||
ownership TEXT NOT NULL CHECK(ownership IN ('owned', 'attached')),
|
||||
config_json TEXT NOT NULL,
|
||||
resource_json TEXT,
|
||||
projection_json TEXT,
|
||||
generation TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX environment_binding_node ON environment_bindings(node_id) WHERE node_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX environment_binding_seat ON environment_bindings(seat);
|
||||
CREATE TABLE environment_operations (
|
||||
operation_id TEXT PRIMARY KEY,
|
||||
binding_id TEXT NOT NULL REFERENCES environment_bindings(binding_id),
|
||||
kind TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL,
|
||||
effect_id TEXT,
|
||||
state TEXT NOT NULL CHECK(state IN ('intent', 'requested', 'confirmed', 'refused')),
|
||||
receipt_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
// A seat activity grant: the authority a workload outside the host's own hook setup, such as
|
||||
// a guest in another environment or a runner inside one, uses to post activity for ONE seat and ONE launch generation.
|
||||
// The install-wide hook token (activity-endpoint.ts) speaks for every seat, so it never goes into a guest; a grant goes
|
||||
// there instead, under the same variable name, so runners need no change. The ingress (routes/activity.ts) resolves
|
||||
// the grant first and only then accepts the seat and generation a post claims.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { monotonicFactory } from "ulid";
|
||||
|
||||
const ulid = monotonicFactory();
|
||||
const TOKEN_PREFIX = "oag_";
|
||||
|
||||
export type ActivityGrant = {
|
||||
grantId: string;
|
||||
nodeId: string;
|
||||
generation: string;
|
||||
issuedAt: string;
|
||||
issuedBy: string;
|
||||
revokedAt: string | null;
|
||||
revokeReason: string | null;
|
||||
};
|
||||
|
||||
export class ActivityGrantError extends Error {
|
||||
constructor(readonly code: "node_not_found" | "invalid_grant_input", message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type Row = { grant_id: string; node_id: string; generation: string; issued_at: string; issued_by: string;
|
||||
revoked_at: string | null; revoke_reason: string | null };
|
||||
|
||||
const sha256 = (token: string): string => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
function toGrant(row: Row): ActivityGrant {
|
||||
return { grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
|
||||
issuedBy: row.issued_by, revokedAt: row.revoked_at, revokeReason: row.revoke_reason };
|
||||
}
|
||||
|
||||
/** A daemon whose database predates migration 143 has no grants, so every grant token is unknown there. */
|
||||
export function hasActivityGrantsTable(db: Database.Database): boolean {
|
||||
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'activity_grants'").get());
|
||||
}
|
||||
|
||||
export class ActivityGrantStore {
|
||||
constructor(private readonly db: Database.Database, private readonly now: () => Date = () => new Date()) {}
|
||||
|
||||
/** Issue the node's grant for `generation`: the occupant generation its launch reserved, which may not be registered
|
||||
* yet. One active grant per node, so this revokes any earlier one ("reissued"): a lost response is reconciled by
|
||||
* issuing again. The token is returned once; only its sha256 is stored. */
|
||||
issue(input: { nodeId: string; generation: string; issuedBy: string }): ActivityGrant & { token: string } {
|
||||
if (!input.nodeId.trim() || !input.generation.trim() || !input.issuedBy.trim()) {
|
||||
throw new ActivityGrantError("invalid_grant_input", "An activity grant needs a node, a launch generation and who issued it.");
|
||||
}
|
||||
const token = `${TOKEN_PREFIX}${randomBytes(32).toString("hex")}`;
|
||||
const grantId = `agrant-${ulid()}`;
|
||||
const issuedAt = this.now().toISOString();
|
||||
this.db.transaction(() => {
|
||||
if (!this.db.prepare("SELECT 1 FROM nodes WHERE id = ?").get(input.nodeId)) {
|
||||
throw new ActivityGrantError("node_not_found", `No seat with node ${input.nodeId}.`);
|
||||
}
|
||||
this.revokeActive(input.nodeId, "reissued", issuedAt);
|
||||
this.db.prepare(`INSERT INTO activity_grants (grant_id, node_id, generation, token_sha256, issued_by, issued_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(grantId, input.nodeId, input.generation, sha256(token), input.issuedBy, issuedAt);
|
||||
})();
|
||||
return { ...this.get(grantId)!, token };
|
||||
}
|
||||
|
||||
/** Revoke one grant, or the node's active grant. Returns how many were revoked; revoking again changes nothing. */
|
||||
revoke(target: { grantId: string } | { nodeId: string }, reason: string): number {
|
||||
const at = this.now().toISOString();
|
||||
if ("nodeId" in target) return this.revokeActive(target.nodeId, reason, at);
|
||||
return this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = ? WHERE grant_id = ? AND revoked_at IS NULL")
|
||||
.run(at, reason, target.grantId).changes;
|
||||
}
|
||||
|
||||
get(grantId: string): ActivityGrant | null {
|
||||
const row = this.db.prepare("SELECT * FROM activity_grants WHERE grant_id = ?").get(grantId) as Row | undefined;
|
||||
return row ? toGrant(row) : null;
|
||||
}
|
||||
|
||||
activeForNode(nodeId: string): ActivityGrant | null {
|
||||
const row = this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
|
||||
return row ? toGrant(row) : null;
|
||||
}
|
||||
|
||||
/** The grant a presented token belongs to, revoked or not, or null for any other token. */
|
||||
authenticate(token: string | null | undefined): ActivityGrant | null {
|
||||
if (!token?.startsWith(TOKEN_PREFIX) || !hasActivityGrantsTable(this.db)) return null;
|
||||
const row = this.db.prepare("SELECT * FROM activity_grants WHERE token_sha256 = ?").get(sha256(token)) as Row | undefined;
|
||||
return row ? toGrant(row) : null;
|
||||
}
|
||||
|
||||
private revokeActive(nodeId: string, reason: string, at: string): number {
|
||||
return this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = ? WHERE node_id = ? AND revoked_at IS NULL")
|
||||
.run(at, reason, nodeId).changes;
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a grant stands against the node's occupant ledger: `current` speaks for the seat; `stale` names an occupant
|
||||
* that has been replaced; `pending` names a reserved generation its launch hasn't registered yet. */
|
||||
export function grantGeneration(grant: ActivityGrant, ledger: {
|
||||
current(nodeId: string): string | null;
|
||||
registered(nodeId: string, generation: string): boolean;
|
||||
}): "current" | "stale" | "pending" {
|
||||
if (ledger.current(grant.nodeId) === grant.generation) return "current";
|
||||
return ledger.registered(grant.nodeId, grant.generation) ? "stale" : "pending";
|
||||
}
|
||||
|
||||
/** The only activity values a guest receives. Build a guest's environment from an allow-list that includes these, never
|
||||
* by copying the host's: the host carries the install-wide token. `guestBaseUrl` is the provider's mapping of the
|
||||
* daemon's listener into the guest: an http(s) origin, with no credentials, path, query or fragment. */
|
||||
export function guestActivityEnv(grant: { token: string; nodeId: string; generation: string }, sessionName: string, guestBaseUrl: string): {
|
||||
OPENRIG_URL: string; OPENRIG_ACTIVITY_HOOK_TOKEN: string; OPENRIG_NODE_ID: string; OPENRIG_SESSION_NAME: string; OPENRIG_OCCUPANT_GENERATION: string;
|
||||
} {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(guestBaseUrl);
|
||||
} catch {
|
||||
throw new ActivityGrantError("invalid_grant_input", `The guest's activity address isn't a URL: ${guestBaseUrl}`);
|
||||
}
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password
|
||||
|| url.pathname !== "/" || url.search || url.hash) {
|
||||
throw new ActivityGrantError("invalid_grant_input", "The guest's activity address must be an http(s) origin, such as http://host.example:7433.");
|
||||
}
|
||||
return { OPENRIG_URL: url.origin, OPENRIG_ACTIVITY_HOOK_TOKEN: grant.token, OPENRIG_NODE_ID: grant.nodeId,
|
||||
OPENRIG_SESSION_NAME: sessionName, OPENRIG_OCCUPANT_GENERATION: grant.generation };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, rmSync } from "node:fs";
|
||||
import { join, posix, resolve } from "node:path";
|
||||
import type { QueueRepository } from "./queue-repository.js";
|
||||
import type { QueueUpdateInput } from "./queue-repository.js";
|
||||
import type { PersistedEvent } from "./types.js";
|
||||
import { EnvironmentError, EnvironmentPlatform, identifier, regularBytes, relativeGuestPath, sha256 } from "./environment-platform.js";
|
||||
|
||||
export type ArtifactSchema = { format: "json-object/v1"; fields: Record<string, "string" | "number" | "boolean">; required: string[] };
|
||||
export type EnvironmentArtifactRequest = { operationId: string; taskId: string; generation: string; path: string;
|
||||
sha256: string; bytes: number; schema: ArtifactSchema; context: Record<string, string | number | boolean> };
|
||||
export type ArtifactCaller = { actor: string; identityProvenance: QueueUpdateInput["identityProvenance"]; identityPrincipal: string | null };
|
||||
export type EnvironmentArtifactReceipt = { version: 1; operationId: string; bindingId: string; nodeId: string;
|
||||
generation: string; taskId: string; path: string; guestPath: string; sha256: string; bytes: number; actor: string;
|
||||
identityProvenance: QueueUpdateInput["identityProvenance"]; identityPrincipal: string | null };
|
||||
const stable = (v: unknown): string => JSON.stringify(v, (_key, x) => x && typeof x === "object" && !Array.isArray(x)
|
||||
? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b))) : x);
|
||||
function refuse(code: string, message: string, status = 409): never { throw new EnvironmentError(code, message, status); }
|
||||
|
||||
/** Transfers and verifies actual bytes; publication and the existing work note reconcile on the same operation. */
|
||||
export class EnvironmentArtifacts {
|
||||
constructor(private readonly platform: EnvironmentPlatform, private readonly queue: QueueRepository, private readonly root: string) {}
|
||||
private contract(input: EnvironmentArtifactRequest): void {
|
||||
identifier(input.operationId, "operationId"); identifier(input.taskId, "taskId"); identifier(input.generation, "generation");
|
||||
relativeGuestPath(input.path);
|
||||
if (!/^[0-9a-f]{64}$/.test(input.sha256) || !Number.isSafeInteger(input.bytes) || input.bytes < 1 || input.bytes > 1024 * 1024)
|
||||
refuse("invalid_artifact_contract", "Expected SHA256 and byte count (1 through 1048576) are required", 400);
|
||||
const schema = input.schema;
|
||||
if (!schema || schema.format !== "json-object/v1" || !schema.fields || Array.isArray(schema.fields)
|
||||
|| !Array.isArray(schema.required) || Object.keys(schema.fields).length > 100
|
||||
|| !Object.values(schema.fields).every(v => ["string", "number", "boolean"].includes(v))
|
||||
|| !schema.required.every(v => typeof v === "string" && Object.hasOwn(schema.fields, v)))
|
||||
refuse("invalid_artifact_schema", "A supported json-object/v1 artifact contract is required", 400);
|
||||
if (!input.context || typeof input.context !== "object" || Array.isArray(input.context)
|
||||
|| Object.keys(input.context).length > 100 || !Object.entries(input.context).every(([key, v]) =>
|
||||
Object.hasOwn(schema.fields, key) && ["string", "number", "boolean"].includes(typeof v)))
|
||||
refuse("invalid_artifact_context", "Context must select declared scalar fields", 400);
|
||||
}
|
||||
private validate(data: Buffer, input: EnvironmentArtifactRequest): void {
|
||||
if (data.length !== input.bytes || sha256(data) !== input.sha256) refuse("artifact_bytes_changed", "Returned bytes do not match the selected artifact");
|
||||
let body: Record<string, unknown>;
|
||||
try { body = JSON.parse(data.toString("utf8")); } catch { refuse("artifact_schema_mismatch", "The artifact is not JSON"); }
|
||||
if (!body! || typeof body! !== "object" || Array.isArray(body!)) refuse("artifact_schema_mismatch", "The artifact must be a JSON object");
|
||||
for (const key of input.schema.required) if (!Object.hasOwn(body!, key)) refuse("artifact_schema_mismatch", `Required artifact field ${key} is missing`);
|
||||
for (const [key, type] of Object.entries(input.schema.fields))
|
||||
if (Object.hasOwn(body!, key) && typeof body![key] !== type) refuse("artifact_schema_mismatch", `Artifact field ${key} has the wrong type`);
|
||||
for (const [key, value] of Object.entries(input.context)) if (body![key] !== value) refuse("artifact_context_mismatch", `Artifact field ${key} does not match the selected work context`);
|
||||
}
|
||||
async export(bindingId: string, input: EnvironmentArtifactRequest, caller: ArtifactCaller): Promise<EnvironmentArtifactReceipt> {
|
||||
this.contract(input);
|
||||
const binding = this.platform.get(bindingId);
|
||||
if (!binding?.resource || !binding.nodeId) refuse("environment_binding_missing", "Export requires a confirmed node binding", 404);
|
||||
if (binding.generation !== input.generation) refuse("environment_generation_changed", "This export does not belong to the selected launch generation");
|
||||
const task = this.queue.getById(input.taskId);
|
||||
if (!task || task.destinationSession !== binding.seat) refuse("artifact_task_mismatch", "The selected work row is not owned by this bound seat");
|
||||
if (caller.actor !== task.destinationSession && caller.actor !== task.sourceSession) refuse("artifact_caller_mismatch", "Only the work owner or its assigning caller may request its export", 403);
|
||||
const db = this.platform.db;
|
||||
const request = stable(input);
|
||||
db.transaction(() => {
|
||||
const old = db.prepare("SELECT binding_id, kind, request_json FROM environment_operations WHERE operation_id = ?").get(input.operationId) as Record<string, string> | undefined;
|
||||
if (old && (old.binding_id !== bindingId || old.kind !== "export" || old.request_json !== request)) refuse("environment_operation_changed", "The export operation input changed");
|
||||
if (!old) db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state) VALUES (?,?,'export',?,'requested')")
|
||||
.run(input.operationId, bindingId, request);
|
||||
})();
|
||||
const targetDir = join(resolve(binding.artifactRoot ?? this.root), bindingId);
|
||||
let current = targetDir;
|
||||
while (current !== resolve(current, "..")) {
|
||||
if (existsSync(current) && lstatSync(current).isSymbolicLink()) refuse("artifact_publication_conflict", "The publication root contains a symlink");
|
||||
current = resolve(current, "..");
|
||||
}
|
||||
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
||||
const unchanged = () => {
|
||||
const now = this.platform.get(bindingId), taskNow = this.queue.getById(input.taskId);
|
||||
if (now?.generation !== input.generation || now.resource?.id !== binding.resource!.id || now.nodeId !== binding.nodeId || taskNow?.destinationSession !== binding.seat)
|
||||
refuse("environment_selection_changed", "The binding or work owner changed during export");
|
||||
};
|
||||
const target = join(targetDir, `${input.taskId}-${input.operationId}.json`);
|
||||
// Reconcile existing validated host bytes BEFORE touching a guest that may already have exited.
|
||||
if (existsSync(target) || (() => { try { return lstatSync(target).isSymbolicLink(); } catch { return false; } })()) this.validate(regularBytes(target), input);
|
||||
else {
|
||||
const stage = mkdtempSync(join(targetDir, ".transfer-"));
|
||||
try {
|
||||
await this.platform.current(bindingId, true);
|
||||
const staged = join(stage, "artifact.json");
|
||||
await this.platform.driver.download(binding, posix.join(binding.guestRoot, "work/artifacts", input.path), staged);
|
||||
this.validate(regularBytes(staged), input);
|
||||
unchanged();
|
||||
const fd = openSync(staged, "r"); try { fsyncSync(fd); } finally { closeSync(fd); }
|
||||
try { linkSync(staged, target); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; this.validate(regularBytes(target), input); }
|
||||
} finally { rmSync(stage, { recursive: true }); }
|
||||
}
|
||||
this.validate(regularBytes(target), input);
|
||||
unchanged();
|
||||
const directory = openSync(targetDir, "r"); try { fsyncSync(directory); } finally { closeSync(directory); }
|
||||
const receipt: EnvironmentArtifactReceipt = { version: 1, operationId: input.operationId, bindingId, nodeId: binding.nodeId,
|
||||
generation: input.generation, taskId: input.taskId, path: target, guestPath: posix.join(binding.guestRoot, "work/artifacts", input.path),
|
||||
sha256: input.sha256, bytes: input.bytes, actor: caller.actor, identityProvenance: caller.identityProvenance, identityPrincipal: caller.identityPrincipal };
|
||||
let events: PersistedEvent[] = [];
|
||||
const saved = db.transaction(() => {
|
||||
unchanged();
|
||||
const old = db.prepare("SELECT receipt_json FROM environment_operations WHERE operation_id = ?").get(input.operationId) as { receipt_json: string | null };
|
||||
if (old.receipt_json) return JSON.parse(old.receipt_json) as EnvironmentArtifactReceipt;
|
||||
const note = `Environment artifact ${input.operationId}: ${JSON.stringify(receipt)}`;
|
||||
// Only the receipt/note changes; never close the child's work on behalf of its owner.
|
||||
const result = this.queue.updateWithinTransaction({ qitemId: input.taskId, actorSession: caller.actor, transitionNote: note,
|
||||
identityProvenance: caller.identityProvenance, identityPrincipal: caller.identityPrincipal });
|
||||
events = result.persistedEvents;
|
||||
db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), input.operationId);
|
||||
return receipt;
|
||||
})();
|
||||
this.queue.notifyCommitted(events);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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) fail("environment_binding_exists", "Create or attach a new binding; existing ownership is immutable");
|
||||
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 op = this.intent(input.operationId, "attach", config, "attached", { config, resourceId: input.resourceId, fingerprint: input.fingerprint });
|
||||
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);
|
||||
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 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: 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,162 @@
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, posix } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ProjectionPlan } from "./projection-planner.js";
|
||||
import type { NodeBinding, ProjectionResult, ResolvedStartupFile, StartupDeliveryResult, HarnessLaunchResult } from "./runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import { EnvironmentPlatform, EnvironmentError, regularBytes, sha256, type ProjectionFile, type ProjectionReceipt } from "./environment-platform.js";
|
||||
import { OpenShellDriver } from "../adapters/openshell-driver.js";
|
||||
|
||||
export type EnvironmentActivity = (input: { nodeId: string; generation: string; seat: string; guestBaseUrl: string }) =>
|
||||
Promise<{ grantId: string; env: Record<string, string> }>;
|
||||
|
||||
/** One maintained consumer of RuntimeAdapter's optional environment operations; no host fallback when selected. */
|
||||
export class EnvironmentStartup {
|
||||
constructor(readonly platform: EnvironmentPlatform, private readonly stageRoot: string, private readonly activity: EnvironmentActivity) {}
|
||||
private 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");
|
||||
if (!selected.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, nodeId: binding.nodeId };
|
||||
}
|
||||
private stage() { mkdirSync(this.stageRoot, { recursive: true, mode: 0o700 }); return mkdtempSync(join(this.stageRoot, "projection-")); }
|
||||
private walk(source: string, destination: string, required: boolean, out: ProjectionFile[]): void {
|
||||
const stat = lstatSync(source);
|
||||
if (stat.isSymbolicLink()) throw new EnvironmentError("environment_projection_symlink", "A declared projection contains a symlink; select its regular destination explicitly");
|
||||
if (stat.isDirectory()) {
|
||||
for (const name of readdirSync(source).sort()) this.walk(join(source, name), posix.join(destination, name), required, out);
|
||||
} else {
|
||||
const bytes = regularBytes(source);
|
||||
out.push({ source, path: destination, sha256: sha256(bytes), required });
|
||||
}
|
||||
}
|
||||
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
|
||||
const selected = this.selected(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.selected(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 });
|
||||
const delivered = await this.platform.project(selected.bindingId, { operationId: `startup-${binding.launchGeneration}`, generation: binding.launchGeneration!, files: staged });
|
||||
const merged = { ...delivered, files: [...previous.files.filter((file: ProjectionReceipt) => !delivered.files.some(next => next.path === file.path)), ...delivered.files] };
|
||||
this.platform.db.prepare("UPDATE environment_bindings SET projection_json = ? WHERE binding_id = ?").run(JSON.stringify(merged), selected.bindingId);
|
||||
return { delivered: files.length, failed: [] };
|
||||
} finally { rmSync(stage, { recursive: true }); }
|
||||
}
|
||||
async launch(binding: NodeBinding, opts: { resumeToken?: string; forkSource?: unknown; beforeNativeDispatch?: () => boolean }, tmux: Pick<TmuxAdapter, "sendShellCommand">): Promise<HarnessLaunchResult> {
|
||||
if (opts.resumeToken || opts.forkSource) return { ok: false, recovery: "attention_required", error: "Environment native restore/fork is not implemented by driver version 1; no fresh conversation was started." };
|
||||
const selected = this.selected(binding), generation = binding.launchGeneration!;
|
||||
await this.platform.validate(selected.bindingId, generation);
|
||||
const operationId = `launch-${generation}`;
|
||||
const prior = this.platform.db.prepare("SELECT binding_id, kind, state, request_json, receipt_json FROM environment_operations WHERE operation_id = ?").get(operationId) as { binding_id: string; kind: string; state: string; request_json: string; receipt_json: string | null } | undefined;
|
||||
if (prior) {
|
||||
const request = JSON.parse(prior.request_json);
|
||||
if (prior.binding_id !== selected.bindingId || prior.kind !== "launch" || request.nodeId !== binding.nodeId || request.generation !== generation || request.resourceId !== selected.resource!.id)
|
||||
throw new EnvironmentError("environment_operation_changed", "The saved native launch belongs to a different binding");
|
||||
// Reconcile the original execution before issuing grants or changing any guest inputs.
|
||||
// Intent is conservative too: a crash may have happened after any awaited write.
|
||||
if (!(this.platform.driver instanceof OpenShellDriver)) throw new EnvironmentError("environment_native_unconfirmed", "The saved execution needs live identity reconciliation");
|
||||
const observed = await this.platform.driver.guest(selected, ["--identity"]);
|
||||
if (observed.nativeId !== request.nativeId || observed.generation !== generation)
|
||||
throw new EnvironmentError("environment_native_unconfirmed", "The original native identity is unconfirmed; no grant or execution was replaced");
|
||||
const receipt = { ...request, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: null };
|
||||
this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId);
|
||||
return { ok: true, resumeToken: request.nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher", warnings: ["The original native identity was reconciled; viewer readiness still requires its existing observation and no viewer input was replayed."] };
|
||||
}
|
||||
const older = this.platform.db.prepare("SELECT operation_id FROM environment_operations WHERE binding_id = ? AND kind = 'launch' AND operation_id <> ? AND state IN ('requested','confirmed')").get(selected.bindingId, operationId);
|
||||
if (older) throw new EnvironmentError("environment_prior_execution_unconfirmed", "A prior native execution must be reconciled through lifecycle/restore before another launch");
|
||||
if (!selected.coordinationRef) throw new EnvironmentError("environment_coordination_missing", "Configure this binding's scoped MCP work connection before native launch");
|
||||
const connection = JSON.parse(regularBytes(selected.coordinationRef).toString("utf8")) as { version?: number; mcpUrl?: string; seat?: string; token?: string };
|
||||
if (connection.version !== 1 || connection.seat !== selected.seat || typeof connection.token !== "string" || !connection.token || typeof connection.mcpUrl !== "string")
|
||||
throw new EnvironmentError("environment_coordination_invalid", "The managed work connection does not belong to this bound seat");
|
||||
const coordination = Buffer.from(JSON.stringify({ mcpServers: { openrig: { type: "http", url: connection.mcpUrl, headers: { Authorization: `Bearer ${connection.token}` } } } }));
|
||||
const row = this.platform.db.prepare("SELECT projection_json FROM environment_bindings WHERE binding_id = ?").get(selected.bindingId) as { projection_json: string };
|
||||
const files = JSON.parse(row.projection_json).files as ProjectionReceipt[];
|
||||
const nativeId = randomUUID();
|
||||
const request = { version: 1, bindingId: selected.bindingId, nodeId: binding.nodeId, generation, resourceId: selected.resource!.id, nativeId };
|
||||
this.platform.db.prepare("INSERT OR IGNORE INTO environment_operations(operation_id,binding_id,kind,request_json,state) VALUES (?,?,'launch',?,'intent')")
|
||||
.run(operationId, selected.bindingId, JSON.stringify(request));
|
||||
const stage = this.stage();
|
||||
try {
|
||||
const activity = await this.activity({ nodeId: binding.nodeId, generation, seat: selected.seat, guestBaseUrl: selected.guestBaseUrl });
|
||||
const putPrivate = async (name: string, data: string) => {
|
||||
const local = join(stage, name); writeFileSync(local, data, { mode: 0o600 });
|
||||
const destination = posix.join(selected.guestRoot, "control", name);
|
||||
let previous: string | undefined;
|
||||
try { previous = sha256(await this.platform.driver.read(selected, destination)); }
|
||||
catch (error) { if (!(error instanceof EnvironmentError && error.code === "environment_file_missing")) throw error; }
|
||||
await this.platform.driver.upload(selected, local, destination, previous);
|
||||
};
|
||||
this.platform.db.prepare("UPDATE environment_operations SET request_json = ? WHERE operation_id = ? AND state = 'intent'").run(JSON.stringify({ ...request, activityGrantId: activity.grantId }), operationId);
|
||||
await putPrivate("activity.json", JSON.stringify(activity.env));
|
||||
await putPrivate("coordination.json", coordination.toString("utf8"));
|
||||
const descriptor = { ...request, files, model: binding.model, effort: binding.effort, permissionMode: binding.permissionMode,
|
||||
plugins: files.filter(file => file.path.includes("/.claude/plugins/") && file.path.endsWith("/.claude-plugin/plugin.json"))
|
||||
.map(file => posix.join(selected.guestRoot, file.path.slice(0, -"/.claude-plugin/plugin.json".length))) };
|
||||
await putPrivate("launch.pending.json", JSON.stringify(descriptor));
|
||||
await this.platform.validate(selected.bindingId, generation);
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected launch changed before native dispatch");
|
||||
if (!(this.platform.driver instanceof OpenShellDriver)) throw new EnvironmentError("environment_launch_unsupported", "This driver does not implement native launch preparation");
|
||||
const claimed = this.platform.db.prepare("UPDATE environment_operations SET state = 'requested' WHERE operation_id = ? AND state = 'intent'").run(operationId);
|
||||
if (!claimed.changes) throw new EnvironmentError("environment_native_unconfirmed", "The saved native launch intent needs reconciliation; no second launch was attempted");
|
||||
await this.platform.driver.guest(selected, ["--prepare"]);
|
||||
const command = ["rig", "environment", "connect", selected.bindingId, "--generation", generation].map(shellQuote).join(" ");
|
||||
const sent = await tmux.sendShellCommand(binding.tmuxSession!, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok) throw new EnvironmentError("environment_viewer_unconfirmed", "The native execution may have started; its viewer dispatch is unconfirmed");
|
||||
let observed: Record<string, any> | undefined;
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try { observed = await this.platform.driver.guest(selected, ["--identity"]); break; }
|
||||
catch { await new Promise(resolve => setTimeout(resolve, 250)); }
|
||||
}
|
||||
if (observed?.nativeId !== nativeId || observed.generation !== generation) throw new EnvironmentError("environment_native_unconfirmed", "The native process identity is not confirmed; nothing was restarted");
|
||||
const receipt = { ...request, activityGrantId: activity.grantId, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: null };
|
||||
this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId);
|
||||
return { ok: true, resumeToken: nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher" };
|
||||
} finally { rmSync(stage, { recursive: true }); }
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,9 @@ 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">;
|
||||
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;
|
||||
|
||||
@@ -398,6 +398,7 @@ export class StartupOrchestrator {
|
||||
&& input.forkSource?.kind === "native_id" && input.forkSource.value?.trim()
|
||||
? { parentToken: input.forkSource.value.trim(), isRestore: input.isRestore } : undefined;
|
||||
const launchGeneration = this.sessionRegistry.currentOccupantTenure(input.nodeId)?.generationUuid;
|
||||
if (input.adapter.environment) input = { ...input, binding: { ...input.binding, launchGeneration: input.binding.launchGeneration ?? launchGeneration } };
|
||||
|
||||
// 1. Mark pending
|
||||
this.sessionRegistry.updateStartupStatus(input.sessionId, "pending");
|
||||
@@ -451,6 +452,13 @@ export class StartupOrchestrator {
|
||||
&& isSeatSkillsBootstrapLine(f));
|
||||
const applicableFiles = sourceFiles.filter((f) => f.appliesOn.includes(context) && !input.replaySkip?.files.has(f.absolutePath)
|
||||
&& !isSeatSkillsBootstrapLine(f));
|
||||
if (input.adapter.environment) {
|
||||
try {
|
||||
if (!input.adapter.prepareEnvironmentStartup) throw new Error("Selected environment does not support required startup projection");
|
||||
const prepared = await input.adapter.prepareEnvironmentStartup(applicableFiles, input.binding);
|
||||
if (prepared.failed.length) return this.fail(input, "failed", prepared.failed.map(file => `Required environment startup input ${file.path}: ${file.error}`), undefined, false, undefined, false, "pre_launch_files", true);
|
||||
} catch (error) { return this.fail(input, "failed", [`Environment startup preparation: ${(error as Error).message}`], undefined, false, undefined, false, "pre_launch_files", true); }
|
||||
}
|
||||
const preLaunchFiles: ResolvedStartupFile[] = [];
|
||||
let postLaunchFiles: ResolvedStartupFile[] = [];
|
||||
for (const f of applicableFiles) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { runtimeSwitchActivityRole } from "./runtime-switch-custody.js";
|
||||
import { physicalSessionName, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "../domain/session-registry.js";
|
||||
import { SessionRegistry, physicalSessionName, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "../domain/session-registry.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { AgentActivityStore } from "../domain/agent-activity-store.js";
|
||||
import { hookEventState, isRunnerExit } from "../domain/agent-activity-store.js";
|
||||
import type { SessionRegistry } from "../domain/session-registry.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { verifyStartupProof } from "../domain/startup-proof.js";
|
||||
import { ActivityGrantStore, grantGeneration, type ActivityGrant } from "../domain/activity-grants.js";
|
||||
import type { ActivityEvidence, EvidenceRoute } from "../domain/activity-taxonomy.js";
|
||||
import type { AgentActivity } from "../domain/types.js";
|
||||
import * as parkedQuery from "../domain/parked-query.js";
|
||||
@@ -110,6 +110,42 @@ function nextHookSeq(key: string): number {
|
||||
|
||||
export const activityRoutes = new Hono();
|
||||
|
||||
/** A seat activity grant's post, bound to the grant. A post that names another seat, or claims a generation the grant
|
||||
* wasn't issued for, is refused; so is a grant whose occupant was replaced (stale) or hasn't registered yet (pending).
|
||||
* A refusal records nothing, so a missing event stays unknown. Otherwise the seat and generation come from the grant. */
|
||||
function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant, body: Record<string, unknown>):
|
||||
{ body: Record<string, unknown> } | { refused: Response } {
|
||||
const refuse = (status: 403 | 409 | 503, code: string, error: string) => ({ refused: c.json({ ok: false, code, error }, status) });
|
||||
const claimedNode = stringOrNull(body.nodeId);
|
||||
const claimedSession = stringOrNull(body.sessionName);
|
||||
if ((claimedNode && claimedNode !== grant.nodeId)
|
||||
|| (claimedSession && store.resolveSession({ sessionName: claimedSession })?.nodeId !== grant.nodeId)) {
|
||||
return refuse(403, "activity_grant_wrong_seat", "This activity grant speaks only for its own seat; nothing was recorded.");
|
||||
}
|
||||
const claimedGeneration = stringOrNull(body.generation);
|
||||
if (claimedGeneration && claimedGeneration !== grant.generation) {
|
||||
return refuse(409, "activity_grant_stale", "This post claims a launch generation its grant wasn't issued for; nothing was recorded.");
|
||||
}
|
||||
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
|
||||
let standing: ReturnType<typeof grantGeneration>;
|
||||
try {
|
||||
standing = grantGeneration(grant, {
|
||||
current: (nodeId) => registry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
|
||||
registered: (nodeId, generation) => registry.isOccupantGenerationRegistered(nodeId, generation),
|
||||
});
|
||||
} catch {
|
||||
return refuse(503, "generation_resolver_error", "Occupant generation is unavailable.");
|
||||
}
|
||||
if (standing === "stale") {
|
||||
return refuse(409, "activity_grant_stale", "This activity grant's launch generation no longer occupies the seat; nothing was recorded.");
|
||||
}
|
||||
if (standing === "pending") {
|
||||
return refuse(409, "activity_grant_pending", "This activity grant's launch hasn't registered its occupant yet; nothing was recorded.");
|
||||
}
|
||||
const sessionName = claimedSession ?? store.resolveSession({ nodeId: grant.nodeId })?.sessionName ?? null;
|
||||
return { body: { ...body, nodeId: grant.nodeId, generation: grant.generation, ...(sessionName ? { sessionName } : {}) } };
|
||||
}
|
||||
|
||||
/** The shared front of the Pi usage routes: configuration, the local hook token, a JSON object body, and the
|
||||
* managed Pi seat it names. Returns the response to send on any failure. */
|
||||
async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegistry["db"]) => boolean):
|
||||
@@ -199,12 +235,22 @@ activityRoutes.post("/hooks", async (c) => {
|
||||
const authHeader = c.req.header("authorization");
|
||||
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length).trim() : null;
|
||||
const headerToken = c.req.header("x-openrig-activity-token") ?? null;
|
||||
// The principal comes first. The install-wide token speaks for every seat, as before. A seat activity grant speaks
|
||||
// only for its own seat and launch generation, which bindToGrant checks before anything the post claims is accepted.
|
||||
let grant: ActivityGrant | null = null;
|
||||
if (bearerToken !== expectedToken && headerToken !== expectedToken) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
code: "activity_hook_unauthorized",
|
||||
error: "Agent activity hook ingestion requires the configured local hook token.",
|
||||
}, 401);
|
||||
const grants = new ActivityGrantStore(store.db);
|
||||
grant = grants.authenticate(bearerToken) ?? grants.authenticate(headerToken);
|
||||
if (!grant) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
code: "activity_hook_unauthorized",
|
||||
error: "Agent activity hook ingestion requires the configured local hook token.",
|
||||
}, 401);
|
||||
}
|
||||
if (grant.revokedAt) {
|
||||
return c.json({ ok: false, code: "activity_grant_revoked", error: "This activity grant was revoked; nothing was recorded." }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
@@ -213,8 +259,16 @@ activityRoutes.post("/hooks", async (c) => {
|
||||
} catch {
|
||||
return c.json({ ok: false, code: "invalid_json", error: "Request body must be JSON." }, 400);
|
||||
}
|
||||
if (grant && (body === null || typeof body !== "object" || Array.isArray(body))) {
|
||||
return c.json({ ok: false, code: "invalid_json", error: "Request body must be a JSON object." }, 400);
|
||||
}
|
||||
|
||||
if (typeof body.sessionName === "string") body = { ...body, sessionName: physicalSessionName(store.db, body.sessionName) };
|
||||
if (grant) {
|
||||
const bound = bindToGrant(c, store, grant, body);
|
||||
if ("refused" in bound) return bound.refused;
|
||||
body = bound.body;
|
||||
}
|
||||
|
||||
// Slice 16: a runtime switch's staged successor (before commit) and retired predecessor (after) don't speak for the
|
||||
// seat. The staged successor's native identity is kept on the switch record, which the commit applies.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Hono, type Context } from "hono";
|
||||
import { EnvironmentPlatform, EnvironmentError, type EnvironmentConfig } from "../domain/environment-platform.js";
|
||||
import { EnvironmentArtifacts, type EnvironmentArtifactRequest } from "../domain/environment-artifacts.js";
|
||||
import type { QueueRepository } from "../domain/queue-repository.js";
|
||||
import { requireSenderIdentity, resolveRecordedIdentity } from "./require-sender-identity.js";
|
||||
|
||||
export const environmentRoutes = new Hono();
|
||||
function platform(c: Context): EnvironmentPlatform { return c.get("environmentPlatform" as never) as EnvironmentPlatform; }
|
||||
function failure(c: Context, error: unknown): Response {
|
||||
if (error instanceof EnvironmentError) return c.json({ error: error.message, code: error.code }, error.status as 400);
|
||||
return c.json({ code: "environment_unconfirmed", error: "The environment operation did not return a confirmed result; its intent is retained" }, 503);
|
||||
}
|
||||
environmentRoutes.post("/create", async c => {
|
||||
const identity = requireSenderIdentity(c, { verb: "environment create" }); if (!identity.ok) return identity.response;
|
||||
try {
|
||||
const input = await c.req.json<EnvironmentConfig & { operationId: string }>();
|
||||
const result = await platform(c).create(input);
|
||||
return c.json({ ...result.binding, ...(result.code ? { code: result.code } : {}) }, result.status as 201);
|
||||
} catch (error) { return failure(c, error); }
|
||||
});
|
||||
environmentRoutes.post("/attach", async c => {
|
||||
const identity = requireSenderIdentity(c, { verb: "environment attach" }); if (!identity.ok) return identity.response;
|
||||
try {
|
||||
const result = await platform(c).attach(await c.req.json());
|
||||
return c.json(result.binding, result.status as 201);
|
||||
} catch (error) { return failure(c, error); }
|
||||
});
|
||||
environmentRoutes.get("/:bindingId", async c => {
|
||||
try {
|
||||
const service = platform(c);
|
||||
const binding = c.req.query("live") === "true" ? await service.current(c.req.param("bindingId")) : service.get(c.req.param("bindingId"));
|
||||
if (!binding) return c.json({ code: "environment_binding_missing", error: "No environment binding" }, 404);
|
||||
return c.json({ ...binding, observation: c.req.query("live") === "true" ? "live-resource" : "stored-receipt",
|
||||
lifecycle: { closeViewer: "unsupported", stopNative: "unsupported", stopCompute: "unsupported", deleteEnvironment: "unsupported", nativeRestore: "unsupported" } });
|
||||
} catch (error) { return failure(c, error); }
|
||||
});
|
||||
environmentRoutes.post("/:bindingId/project", async c => {
|
||||
const identity = requireSenderIdentity(c, { verb: "environment project" }); if (!identity.ok) return identity.response;
|
||||
try { return c.json(await platform(c).project(c.req.param("bindingId"), await c.req.json())); }
|
||||
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); }
|
||||
});
|
||||
environmentRoutes.post("/:bindingId/effect", async c => {
|
||||
try { const body = await c.req.json<{ effect: string }>(); return platform(c).effect(c.req.param("bindingId"), body.effect); }
|
||||
catch (error) { return failure(c, error); }
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { EnvironmentPlatform, type EnvironmentDriver } from "./domain/environment-platform.js";
|
||||
import { OpenShellDriver } from "./adapters/openshell-driver.js";
|
||||
import { environmentRoutes } from "./routes/environments.js";
|
||||
import { healthDiagnosisRoutes } from "./routes/health-diagnosis.js";
|
||||
import type { HealthDiagnosisService } from "./domain/health-diagnosis.js";
|
||||
import type { HealthPolicyStore } from "./domain/health-policy.js";
|
||||
@@ -146,6 +149,8 @@ import { createRouteTimingMiddleware } from "./domain/route-timing-recorder.js";
|
||||
import { browserBoundary, type BrowserBoundaryOptions } from "./middleware/browser-boundary.js";
|
||||
|
||||
export interface AppDeps {
|
||||
environmentDriver?: EnvironmentDriver;
|
||||
environmentArtifactRoot?: string;
|
||||
/** Boot-time DB context observations, not a live exclusion guarantee. */
|
||||
databaseWarnings?: string[];
|
||||
proofSourceWatch?: import("./domain/proof/source-watch.js").ProofSourceWatch;
|
||||
@@ -500,6 +505,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
throw new Error("createApp: podInstantiator must share the same db handle");
|
||||
}
|
||||
|
||||
const environmentPlatform = new EnvironmentPlatform(deps.rigRepo.db, deps.environmentDriver ?? new OpenShellDriver());
|
||||
const app = new Hono();
|
||||
if (deps.requestPhaseObserver) app.use("*", deps.requestPhaseObserver.middleware());
|
||||
// A malformed request body is a 400, not the default 500.
|
||||
@@ -511,6 +517,8 @@ export function createApp(deps: AppDeps): Hono {
|
||||
// Inject dependencies into context for all routes
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("rigRepo" as never, deps.rigRepo);
|
||||
c.set("environmentPlatform" as never, environmentPlatform);
|
||||
c.set("environmentArtifactRoot" as never, deps.environmentArtifactRoot ?? nodePath.join(process.cwd(), "artifacts", "environments"));
|
||||
c.set("sessionRegistry" as never, deps.sessionRegistry);
|
||||
c.set("eventBus" as never, deps.eventBus);
|
||||
c.set("nodeLauncher" as never, deps.nodeLauncher);
|
||||
@@ -792,6 +800,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());
|
||||
|
||||
@@ -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";
|
||||
@@ -1140,11 +1143,20 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
},
|
||||
);
|
||||
|
||||
const environmentDriver = new OpenShellDriver();
|
||||
const environmentPlatform = new EnvironmentPlatform(db, environmentDriver);
|
||||
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");
|
||||
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) };
|
||||
});
|
||||
|
||||
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 +1220,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 +1424,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
|
||||
|
||||
const deps: AppDeps = {
|
||||
environmentDriver,
|
||||
environmentArtifactRoot: nodePath.join(runtimeSettings.workspaceRoot, "artifacts", "environments"),
|
||||
databaseWarnings,
|
||||
rigRepo,
|
||||
sessionRegistry,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// A seat activity grant on the existing hook ingress, through the real route, store, occupant ledger and oracle. The
|
||||
// grant is resolved first; only then is the seat and generation a post claims accepted. A refused post records nothing,
|
||||
// so the seat's activity stays unknown. The install-wide token keeps today's behaviour.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { activityRoutes, restoredRunnerHookEvidence } from "../src/routes/activity.js";
|
||||
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
const GUEST = "dev-guest@guest-rig", OTHER = "dev-other@guest-rig", INSTALL_TOKEN = "install-token";
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
|
||||
|
||||
/** Two runner seats on one rig: the guest, which holds the grant, and another seat. */
|
||||
function fixture() {
|
||||
const clock = { now: Date.parse("2026-10-30T03:00:00.000Z") };
|
||||
const db = createFullTestDb();
|
||||
cleanups.push(() => db.close());
|
||||
const rigs = new RigRepository(db);
|
||||
const rig = rigs.createRig("guest-rig");
|
||||
const guest = rigs.addNode(rig.id, "dev.guest", { runtime: "synth" });
|
||||
const other = rigs.addNode(rig.id, "dev.other", { runtime: "synth" });
|
||||
const sessions = new SessionRegistry(db);
|
||||
const occupy = (nodeId: string, name: string, kind?: "handover", reserved?: string) => {
|
||||
const session = sessions.registerSession(nodeId, name, kind, reserved);
|
||||
sessions.updateStatus(session.id, "running");
|
||||
sessions.recordCommunityHarnessSelection(session.id, "synth", true);
|
||||
};
|
||||
occupy(guest.id, GUEST);
|
||||
occupy(other.id, OTHER);
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now: () => new Date(clock.now),
|
||||
resolveOccupantGeneration: id => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
|
||||
isRegisteredOccupantGeneration: (id, generation) => sessions.isOccupantGenerationRegistered(id, generation) });
|
||||
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null }, defaultWindowSeconds: 3,
|
||||
now: () => new Date(clock.now), restoreHookEvidence: (seat, seatRuntime) => restoredRunnerHookEvidence(store, seat, seatRuntime) });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("agentActivityStore" as never, store as never);
|
||||
c.set("activityHookToken" as never, INSTALL_TOKEN as never);
|
||||
c.set("sessionRegistry" as never, sessions as never);
|
||||
c.set("seatActivityService" as never, oracle as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/activity", activityRoutes);
|
||||
const grants = new ActivityGrantStore(db, () => new Date(clock.now));
|
||||
const generation = (nodeId: string) => sessions.currentOccupantTenure(nodeId)!.generationUuid;
|
||||
const issue = (gen = generation(guest.id)) => grants.issue({ nodeId: guest.id, generation: gen, issuedBy: "driver@test" });
|
||||
/** A runner's post; `body` is sent as given, so a test can send malformed JSON. */
|
||||
async function post(token: string | null, body: unknown, header: "bearer" | "x-openrig-activity-token" = "bearer") {
|
||||
clock.now += 1000;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token !== null) headers[header === "bearer" ? "authorization" : header] = header === "bearer" ? `Bearer ${token}` : token;
|
||||
const response = await app.request("/api/activity/hooks", { method: "POST", headers,
|
||||
body: typeof body === "string" ? body : JSON.stringify(body) });
|
||||
return { status: response.status, body: await response.json() as Record<string, unknown> };
|
||||
}
|
||||
const event = (hookEvent: string, subtype: string, identity: Record<string, unknown> = {}) =>
|
||||
({ runtime: "synth", hookEvent, subtype, occurredAt: new Date(clock.now + 1000).toISOString(), ...identity });
|
||||
const recorded = () => (db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.activity'").get() as { n: number }).n;
|
||||
const activity = (nodeId: string) => oracle.getSeatState(nodeId)?.activity ?? "unknown";
|
||||
const replace = (reserved?: string) => occupy(guest.id, GUEST, "handover", reserved);
|
||||
return { guest, other, sessions, grants, generation, issue, post, event, recorded, activity, replace };
|
||||
}
|
||||
|
||||
describe("a seat activity grant on the hook ingress", () => {
|
||||
it("speaks for its own seat: the runner's posts decide the seat's activity, with the seat and generation from the grant", async () => {
|
||||
const f = fixture();
|
||||
const grant = f.issue();
|
||||
expect(await f.post(grant.token, f.event("UserPromptSubmit", "accepted", { sessionName: GUEST }))).toMatchObject({ status: 200 });
|
||||
expect(f.activity(f.guest.id)).toBe("working");
|
||||
// A guest may leave out the seat and generation; the grant supplies them.
|
||||
const idle = await f.post(grant.token, f.event("Stop", "end_turn"), "x-openrig-activity-token");
|
||||
expect(idle).toMatchObject({ status: 200, body: { activity: { generation: grant.generation } } });
|
||||
expect(f.activity(f.guest.id)).toBe("idle-at-prompt");
|
||||
expect(f.recorded()).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the install-wide token's behaviour for every seat", async () => {
|
||||
const f = fixture();
|
||||
f.issue();
|
||||
for (const [nodeId, sessionName] of [[f.guest.id, GUEST], [f.other.id, OTHER]] as const) {
|
||||
expect(await f.post(INSTALL_TOKEN, f.event("UserPromptSubmit", "accepted", { sessionName, nodeId, generation: f.generation(nodeId) })))
|
||||
.toMatchObject({ status: 200 });
|
||||
expect(f.activity(nodeId)).toBe("working");
|
||||
}
|
||||
});
|
||||
|
||||
const wrongSeat: Array<[string, (f: ReturnType<typeof fixture>) => Record<string, unknown>]> = [
|
||||
["another seat's name", () => ({ sessionName: OTHER })],
|
||||
["another seat's node", (f) => ({ nodeId: f.other.id })],
|
||||
["its own node with another seat's name", (f) => ({ nodeId: f.guest.id, sessionName: OTHER })],
|
||||
["a seat that doesn't exist", () => ({ sessionName: "dev-nobody@guest-rig" })],
|
||||
];
|
||||
it.each(wrongSeat)("refuses a post naming %s, and records nothing", async (_case, identity) => {
|
||||
const f = fixture();
|
||||
const grant = f.issue();
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", identity(f))))
|
||||
.toMatchObject({ status: 403, body: { code: "activity_grant_wrong_seat" } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
expect(f.activity(f.other.id)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("refuses a post claiming a generation its grant wasn't issued for", async () => {
|
||||
const f = fixture();
|
||||
const grant = f.issue();
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn", { generation: "another-generation" })))
|
||||
.toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a replaced occupant's grant as stale", async () => {
|
||||
const f = fixture();
|
||||
const grant = f.issue();
|
||||
f.replace();
|
||||
expect(await f.post(grant.token, f.event("Stop", "end_turn"))).toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a grant for a launch that hasn't registered its occupant yet, then accepts it once it has", async () => {
|
||||
const f = fixture();
|
||||
const reserved = f.sessions.reserveOccupantGeneration()!;
|
||||
const grant = f.issue(reserved);
|
||||
expect(await f.post(grant.token, f.event("UserPromptSubmit", "accepted"))).toMatchObject({ status: 409, body: { code: "activity_grant_pending" } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
f.replace(reserved);
|
||||
expect(await f.post(grant.token, f.event("UserPromptSubmit", "accepted"))).toMatchObject({ status: 200 });
|
||||
expect(f.activity(f.guest.id)).toBe("working");
|
||||
});
|
||||
|
||||
it("refuses a revoked grant, including one replaced by a reissue", async () => {
|
||||
const f = fixture();
|
||||
const first = f.issue();
|
||||
const second = f.issue();
|
||||
expect(await f.post(first.token, f.event("Stop", "end_turn"))).toMatchObject({ status: 401, body: { code: "activity_grant_revoked" } });
|
||||
expect(await f.post(second.token, f.event("UserPromptSubmit", "accepted"))).toMatchObject({ status: 200 });
|
||||
f.grants.revoke({ nodeId: f.guest.id }, "stopped");
|
||||
expect(await f.post(second.token, f.event("Stop", "end_turn"))).toMatchObject({ status: 401, body: { code: "activity_grant_revoked" } });
|
||||
expect(f.recorded()).toBe(1);
|
||||
expect(f.activity(f.guest.id)).toBe("working");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown grant", `oag_${"0".repeat(64)}`],
|
||||
["another token", "not-the-token"],
|
||||
["no token", null],
|
||||
])("refuses %s as unauthorized", async (_case, token) => {
|
||||
const f = fixture();
|
||||
f.issue();
|
||||
expect(await f.post(token, f.event("Stop", "end_turn", { sessionName: GUEST })))
|
||||
.toMatchObject({ status: 401, body: { code: "activity_hook_unauthorized" } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([["broken JSON", "{"], ["null", "null"], ["a list", "[]"]])("refuses %s from a grant holder", async (_case, raw) => {
|
||||
const f = fixture();
|
||||
const grant = f.issue();
|
||||
expect(await f.post(grant.token, raw)).toMatchObject({ status: 400, body: { code: "invalid_json" } });
|
||||
expect(f.recorded()).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a seat unknown, never idle, when every post was refused", async () => {
|
||||
const f = fixture();
|
||||
const grant = f.issue();
|
||||
await f.post(grant.token, f.event("Stop", "end_turn", { sessionName: OTHER }));
|
||||
await f.post(grant.token, f.event("Stop", "end_turn", { generation: "another-generation" }));
|
||||
f.grants.revoke({ nodeId: f.guest.id }, "stopped");
|
||||
await f.post(grant.token, f.event("Stop", "end_turn"));
|
||||
expect(f.recorded()).toBe(0);
|
||||
expect(f.activity(f.guest.id)).toBe("unknown");
|
||||
expect(f.activity(f.other.id)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// The seat activity grant's store and the only activity values a guest receives.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { ActivityGrantStore, grantGeneration, guestActivityEnv } from "../src/domain/activity-grants.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
|
||||
|
||||
function fixture() {
|
||||
const db = createFullTestDb();
|
||||
cleanups.push(() => db.close());
|
||||
const rigs = new RigRepository(db);
|
||||
const rig = rigs.createRig("guest-rig");
|
||||
const node = rigs.addNode(rig.id, "dev.guest", { runtime: "synth" });
|
||||
const sessions = new SessionRegistry(db);
|
||||
sessions.registerSession(node.id, "dev-guest@guest-rig");
|
||||
const generation = sessions.currentOccupantTenure(node.id)!.generationUuid;
|
||||
const ledger = { current: (id: string) => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
|
||||
registered: (id: string, g: string) => sessions.isOccupantGenerationRegistered(id, g) };
|
||||
return { db, node, sessions, generation, ledger, grants: new ActivityGrantStore(db) };
|
||||
}
|
||||
|
||||
describe("issuing a seat activity grant", () => {
|
||||
it("returns the token once and stores only its sha256", () => {
|
||||
const f = fixture();
|
||||
const issued = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
|
||||
expect(issued.token).toMatch(/^oag_[0-9a-f]{64}$/);
|
||||
expect(issued).toMatchObject({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test", revokedAt: null });
|
||||
const rows = f.db.prepare("SELECT * FROM activity_grants").all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(JSON.stringify(rows)).not.toContain(issued.token);
|
||||
expect(f.grants.get(issued.grantId)).not.toHaveProperty("token");
|
||||
expect(f.grants.authenticate(issued.token)?.grantId).toBe(issued.grantId);
|
||||
});
|
||||
|
||||
it("keeps one active grant per seat: issuing again revokes the earlier one", () => {
|
||||
const f = fixture();
|
||||
const first = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
|
||||
const second = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
|
||||
expect(f.grants.get(first.grantId)).toMatchObject({ revokeReason: "reissued" });
|
||||
expect(f.grants.get(first.grantId)?.revokedAt).not.toBeNull();
|
||||
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(second.grantId);
|
||||
});
|
||||
|
||||
it("revokes by grant or by seat, once", () => {
|
||||
const f = fixture();
|
||||
const issued = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
|
||||
expect(f.grants.revoke({ nodeId: f.node.id }, "stopped")).toBe(1);
|
||||
expect(f.grants.revoke({ grantId: issued.grantId }, "again")).toBe(0);
|
||||
expect(f.grants.get(issued.grantId)).toMatchObject({ revokeReason: "stopped" });
|
||||
expect(f.grants.activeForNode(f.node.id)).toBeNull();
|
||||
// A revoked grant still authenticates, so the ingress can say it was revoked.
|
||||
expect(f.grants.authenticate(issued.token)?.revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an unknown seat and blank input, and knows no other token", () => {
|
||||
const f = fixture();
|
||||
expect(() => f.grants.issue({ nodeId: "no-such-node", generation: f.generation, issuedBy: "driver@test" })).toThrow(/No seat/);
|
||||
expect(() => f.grants.issue({ nodeId: f.node.id, generation: " ", issuedBy: "driver@test" })).toThrow(/needs a node/);
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM activity_grants").get()).toEqual({ n: 0 });
|
||||
for (const token of [null, "", "install-token", `oag_${"0".repeat(64)}`]) expect(f.grants.authenticate(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("places a grant against the occupant ledger: current, stale after replacement, pending before registration", () => {
|
||||
const f = fixture();
|
||||
const current = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
|
||||
expect(grantGeneration(current, f.ledger)).toBe("current");
|
||||
const reserved = f.sessions.reserveOccupantGeneration()!;
|
||||
const next = f.grants.issue({ nodeId: f.node.id, generation: reserved, issuedBy: "driver@test" });
|
||||
expect(grantGeneration(next, f.ledger)).toBe("pending");
|
||||
f.sessions.registerSession(f.node.id, "dev-guest@guest-rig", "handover", reserved);
|
||||
expect(grantGeneration(next, f.ledger)).toBe("current");
|
||||
expect(grantGeneration(current, f.ledger)).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the activity values a guest receives", () => {
|
||||
const grant = { token: "oag_guest", nodeId: "node-1", generation: "gen-1" };
|
||||
|
||||
it("are exactly the grant, the seat and a guest-reachable origin", () => {
|
||||
expect(guestActivityEnv(grant, "dev-guest@guest-rig", "http://host.containers.internal:7433/")).toEqual({
|
||||
OPENRIG_URL: "http://host.containers.internal:7433", OPENRIG_ACTIVITY_HOOK_TOKEN: "oag_guest", OPENRIG_NODE_ID: "node-1",
|
||||
OPENRIG_SESSION_NAME: "dev-guest@guest-rig", OPENRIG_OCCUPANT_GENERATION: "gen-1" });
|
||||
});
|
||||
|
||||
it.each(["not a url", "ftp://host:7433", "http://host:7433/api/activity", "http://user:secret@host:7433", "http://host:7433/?a=1", "http://host:7433/#x"])(
|
||||
"refuse %s as the guest's address", (address) => {
|
||||
expect(() => guestActivityEnv(grant, "dev-guest@guest-rig", address)).toThrow(/guest's activity address/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
|
||||
const cleanup: Array<() => void> = [];
|
||||
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
|
||||
async function fixture(attached = false) {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-lifecycle-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const db = createFullTestDb(); cleanup.push(() => db.close());
|
||||
const resource = { id: "compute-one", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
|
||||
let present = true, running = true, unavailable = false, loseStop = false, stopCalls = 0, deleteCalls = 0, creates = 0;
|
||||
let dependents = { workload: false, supervisor: false, volumes: false };
|
||||
const files = new Map<string, Buffer>();
|
||||
const native = { nativeId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", nativeType: "community:openshell-claude", generation: "generation-one" };
|
||||
const history = Buffer.from(JSON.stringify({ sessionId: native.nativeId, type: "user", message: { content: "retained context" } }) + "\n");
|
||||
files.set(`/sandbox/openrig/native/projects/-sandbox-openrig-work/${native.nativeId}.jsonl`, history);
|
||||
const driver = { version: 1 as const,
|
||||
list: async () => { if (unavailable) throw new Error("provider unavailable"); return { complete: true, resources: present ? [{ ...resource }] : [] }; },
|
||||
create: async (input: { operationId: string }) => { creates++; present = true; resource.operationId = input.operationId; return { ...resource }; },
|
||||
inspect: async () => { if (unavailable || !present) throw new Error("lookup unavailable"); return { ...resource }; },
|
||||
upload: async (_b: unknown, source: string, destination: string) => { files.set(destination, readFileSync(source)); },
|
||||
read: async (_b: unknown, destination: string) => { const data = files.get(destination); if (!data) throw new Error("missing"); return data; },
|
||||
download: async (_b: unknown, source: string, destination: string) => { writeFileSync(destination, files.get(source)!); },
|
||||
nativeIdentity: async () => ({ ...native, running }),
|
||||
stopNative: async () => { stopCalls++; running = false; if (loseStop) throw new Error("reply lost"); },
|
||||
stopCompute: async () => { stopCalls++; running = false; resource.phase = "Stopped"; if (loseStop) throw new Error("reply lost"); },
|
||||
deleteEnvironment: async () => { deleteCalls++; present = false; },
|
||||
cleanupState: async () => ({ ...dependents }),
|
||||
checkpointFiles: async () => [{ path: `native/projects/-sandbox-openrig-work/${native.nativeId}.jsonl`, bytes: history.length, sha256: (await import("../src/domain/environment-platform.js")).sha256(history) }],
|
||||
};
|
||||
const f = createTestApp(db, { appDeps: { environmentDriver: driver, environmentArtifactRoot: join(root, "artifacts"), environmentStateRoot: join(root, "retained") } as never });
|
||||
const rig = f.rigRepo.createRig("lifecycle"), node = f.rigRepo.addNode(rig.id, "worker", { runtime: "openshell-claude", cwd: root });
|
||||
const request = async (path: string, body?: unknown) => { const res = await f.app.request(`/api/environments${path}`, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-openrig-session": "coordinator@lifecycle" }, ...(body ? { body: JSON.stringify(body) } : {}) }); 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).toBeLessThan(300);
|
||||
db.prepare("UPDATE environment_bindings SET generation='generation-one' WHERE binding_id='binding-one'").run();
|
||||
const receipt = { version: 1, bindingId: "binding-one", nodeId: node.id, resourceId: resource.id, ...native, activityGrantId: "grant-one" };
|
||||
db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state,receipt_json) VALUES ('launch-generation-one','binding-one','launch',?,'confirmed',?)").run(JSON.stringify(receipt), JSON.stringify(receipt));
|
||||
const effect = (name: string, operationId = "effect-one") => request("/binding-one/effect", { operationId, generation: "generation-one", effect: name });
|
||||
return { root, db, request, effect, resource, native, files, history, counts: () => ({ stopCalls, deleteCalls, creates }), loseStop: () => { loseStop = true; }, unavailable: () => { unavailable = true; }, dirtyCleanup: () => { dependents.volumes = true; } };
|
||||
}
|
||||
describe("owned and attached environment effects", () => {
|
||||
it("stops only the selected native and reconciles a lost acknowledgement without a second signal", async () => {
|
||||
const f = await fixture(); f.loseStop();
|
||||
const first = await f.effect("stop-native"); expect(first.body.state).toBe("confirmed");
|
||||
expect(first.body.effect).toBe("stop-native");
|
||||
const replay = await f.effect("stop-native"); expect(replay.body).toEqual(first.body); expect(f.counts().stopCalls).toBe(1);
|
||||
expect(f.resource.phase).toBe("Ready"); expect(f.counts().deleteCalls).toBe(0);
|
||||
});
|
||||
it("observes stopped compute independently from native and viewer state", async () => {
|
||||
const f = await fixture(); const result = await f.effect("stop-compute");
|
||||
expect(result.body).toMatchObject({ state: "confirmed", effect: "stop-compute", resourceId: "compute-one" });
|
||||
expect(f.resource.phase).toBe("Stopped"); expect(f.counts().deleteCalls).toBe(0);
|
||||
});
|
||||
it.each(["stop-native", "stop-compute", "delete-owned"])("preserves attached resources for %s", async effect => {
|
||||
const f = await fixture(true), result = await f.effect(effect);
|
||||
expect(result.status).toBe(409); expect(result.body.code).toBe("environment_attached_preserved");
|
||||
expect(f.counts()).toMatchObject({ stopCalls: 0, deleteCalls: 0, creates: 0 });
|
||||
});
|
||||
it("does not call provider absence complete until workload, supervisor and volumes are all observed absent", async () => {
|
||||
const f = await fixture(); f.dirtyCleanup();
|
||||
const result = await f.effect("delete-owned"); expect(result.body.state).toBe("unconfirmed");
|
||||
expect(result.body.remaining.volumes).toBe(true); expect(f.counts().deleteCalls).toBe(1);
|
||||
await f.effect("delete-owned"); expect(f.counts().deleteCalls).toBe(1);
|
||||
});
|
||||
it("keeps the original operation pending when a lookup fails and never infers absence", async () => {
|
||||
const f = await fixture(); f.unavailable();
|
||||
const result = await f.effect("delete-owned"); expect(result.body.state).toBe("unconfirmed");
|
||||
expect(f.counts().deleteCalls).toBe(0);
|
||||
});
|
||||
it("refuses the wrong generation before any provider mutation", async () => {
|
||||
const f = await fixture(), result = await f.request("/binding-one/effect", { operationId: "stale", generation: "older", effect: "stop-compute" });
|
||||
expect(result.status).toBe(409); expect(f.counts().stopCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
describe("exact contained checkpoint and restore", () => {
|
||||
it("retains verified private history bytes and nonsecret exact native/compute references", async () => {
|
||||
const f = await fixture(), result = await f.request("/binding-one/checkpoint", { operationId: "checkpoint-one", generation: "generation-one" });
|
||||
expect(result.status).toBe(200); expect(result.body).toMatchObject({ version: 1, bindingId: "binding-one", nativeId: f.native.nativeId, nativeType: "community:openshell-claude", predecessorResourceId: "compute-one" });
|
||||
expect(result.body.files).toHaveLength(1); expect(readFileSync(result.body.files[0].source)).toEqual(f.history);
|
||||
expect(JSON.stringify(result.body)).not.toContain("retained context");
|
||||
});
|
||||
it("rejects missing checkpoint state before creating successor compute", async () => {
|
||||
const f = await fixture(), result = await f.request("/binding-one/recreate", { operationId: "recreate-one", generation: "generation-two", checkpointId: "missing", resumeType: "community:openshell-claude", resumeToken: f.native.nativeId, placement: { ...f.resource, name: "worker-two" } });
|
||||
expect(result.status).toBe(409); expect(f.counts().creates).toBe(1);
|
||||
});
|
||||
it("refuses another native type without starting a fresh conversation", async () => {
|
||||
const f = await fixture(), result = await f.request("/binding-one/recreate", { operationId: "recreate-one", generation: "generation-two", checkpointId: "missing", resumeType: "codex_id", resumeToken: "foreign" });
|
||||
expect(result.status).toBe(409); expect(f.counts().creates).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
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 } 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 build = () => createTestApp(db, { appDeps: { 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:7433",
|
||||
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 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).toBeLessThan(300);
|
||||
};
|
||||
return { root, db, resources, files, driver, input, request, create, task, health: () => f.app.request("/healthz"),
|
||||
counts: () => ({ creates, downloads }), loseCreate: () => { loseCreate = true; }, restart: () => { f = build(); } };
|
||||
}
|
||||
|
||||
describe("maintained environment binding and projection", () => {
|
||||
it("journals create intent before the provider effect and persists the immutable owned receipt", async () => {
|
||||
const f = fixture(), result = await f.create();
|
||||
expect(result.status).toBe(201);
|
||||
expect(result.body).toMatchObject({ version: 1, bindingId: "binding-one", ownership: "owned", nodeId: f.input.nodeId,
|
||||
resource: { id: "immutable-resource-1", workspace: "workspace" } });
|
||||
expect(f.counts().creates).toBe(1);
|
||||
expect((await f.request("/binding-one")).body).toMatchObject({ bindingId: "binding-one", ownership: "owned" });
|
||||
});
|
||||
|
||||
it("reconciles a lost create response after daemon reconstruction without another create", async () => {
|
||||
const f = fixture(); f.loseCreate();
|
||||
expect((await f.create()).status).toBe(202);
|
||||
f.restart();
|
||||
const result = await f.create();
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.resource.id).toBe("immutable-resource-1");
|
||||
expect(f.counts().creates).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps an unresolved create intent when the provider cannot establish absence", async () => {
|
||||
const f = fixture(); f.loseCreate();
|
||||
expect((await f.create()).status).toBe(202);
|
||||
f.driver.list = async () => ({ complete: false, resources: [] });
|
||||
expect((await f.create()).status).toBe(202);
|
||||
expect(f.counts().creates).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses duplicate matches instead of choosing a resource or repeating create", async () => {
|
||||
const f = fixture(); f.loseCreate();
|
||||
expect((await f.create()).status).toBe(202);
|
||||
f.resources.push({ ...f.resources[0], id: "conflicting-resource" });
|
||||
expect((await f.create()).status).toBe(409);
|
||||
expect(f.counts().creates).toBe(1);
|
||||
});
|
||||
|
||||
it("does not adopt a different daemon's resource with the same user operation name", async () => {
|
||||
const f = fixture();
|
||||
f.resources.push({ id: "foreign-resource", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" });
|
||||
expect((await f.create()).status).toBe(409); expect(f.counts().creates).toBe(0);
|
||||
expect((await f.request("/binding-one")).body.resource).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reuse an operation identity with different placement", async () => {
|
||||
const f = fixture(); expect((await f.create()).status).toBe(201);
|
||||
expect((await f.request("/create", { ...f.input, placement: { ...f.input.placement, name: "different" } })).status).toBe(409);
|
||||
expect(f.counts().creates).toBe(1);
|
||||
});
|
||||
|
||||
it("attaches only the exact live ID and retains external ownership", async () => {
|
||||
const f = fixture(); f.resources.push({ id: "external-id", name: "worker-one", workspace: "workspace", fingerprint: "host-key-1", phase: "Ready" });
|
||||
const result = await f.request("/attach", { ...f.input, operationId: "attach-one", resourceId: "external-id", fingerprint: "host-key-1" });
|
||||
expect(result.status).toBe(201);
|
||||
expect(result.body).toMatchObject({ ownership: "attached", resource: { id: "external-id" } });
|
||||
expect(f.counts().creates).toBe(0);
|
||||
expect((await f.request("/binding-one/effect", { effect: "delete_environment" })).status).toBe(409);
|
||||
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.request("/binding-one/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.request("/binding-one/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.request("/binding-one/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.request("/binding-one/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { EnvironmentError, EnvironmentPlatform, sha256 } from "../src/domain/environment-platform.js";
|
||||
import { EnvironmentStartup } from "../src/domain/environment-startup.js";
|
||||
import { OpenShellDriver } from "../src/adapters/openshell-driver.js";
|
||||
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
|
||||
const cleanup: Array<() => unknown> = [];
|
||||
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
|
||||
async function fixture() {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-startup-")));
|
||||
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const db = createFullTestDb(); cleanup.push(() => db.close());
|
||||
const app = createTestApp(db), rig = app.rigRepo.createRig("startup-fixture");
|
||||
const node = app.rigRepo.addNode(rig.id, "team.worker", { runtime: "openshell-claude", cwd: root });
|
||||
const files = new Map<string, Buffer>(), resource = { id: "resource-one", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
|
||||
let native: Record<string, unknown> | undefined, losePrepare = false;
|
||||
const guest = vi.fn(async (_binding: unknown, args: string[]) => {
|
||||
if (args[0] === "--prepare") {
|
||||
native = JSON.parse(files.get("/sandbox/openrig/control/launch.pending.json")!.toString());
|
||||
if (losePrepare) throw new EnvironmentError("openshell_transport_unconfirmed", "Lost preparation reply");
|
||||
return { ok: true, ...native };
|
||||
}
|
||||
if (!native) throw new EnvironmentError("environment_native_unconfirmed", "Native is not observed");
|
||||
return { ok: true, ...native, pid: 1 };
|
||||
});
|
||||
// The fixed CLI boundary is replaced; no OpenShell executable or provider is invoked.
|
||||
const driver = Object.assign(new OpenShellDriver(async () => { throw new Error("unexpected vendor invocation"); }), {
|
||||
list: async () => ({ complete: true, resources: [] }), create: async (input: { operationId: string }) => { resource.operationId = input.operationId; return resource; }, inspect: async () => resource,
|
||||
upload: async (_binding: unknown, source: string, destination: string) => { files.set(destination, readFileSync(source)); },
|
||||
read: async (_binding: unknown, path: string) => { const data = files.get(path); if (!data) throw new EnvironmentError("environment_file_missing", "No file"); return data; },
|
||||
guest,
|
||||
});
|
||||
const platform = new EnvironmentPlatform(db, driver), policy = join(root, "policy.yaml"), connection = join(root, "work.json");
|
||||
writeFileSync(policy, "version: 1\n");
|
||||
writeFileSync(connection, JSON.stringify({ version: 1, seat: "team-worker@startup-fixture", mcpUrl: "http://guest.test:7781/mcp", token: "fixture-work-token" }), { mode: 0o600 });
|
||||
await platform.create({ version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id, seat: "team-worker@startup-fixture", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7433", coordinationRef: connection,
|
||||
placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } });
|
||||
const activity = vi.fn(async () => ({ grantId: "grant-one", env: { OPENRIG_ACTIVITY_HOOK_TOKEN: "scoped-fixture-token" } }));
|
||||
const startup = new EnvironmentStartup(platform, join(root, "stage"), activity);
|
||||
const binding = { nodeId: node.id, tmuxSession: "team-worker@startup-fixture", cwd: root, launchGeneration: "generation-one" } as NodeBinding;
|
||||
const plan = { runtime: "openshell-claude", cwd: root, entries: [], conflicts: [], noOps: [], diagnostics: [], startup: { files: [], actions: [] } } as ProjectionPlan;
|
||||
const tmux = { sendShellCommand: vi.fn(async () => ({ ok: true as const })) };
|
||||
await startup.project(plan, binding);
|
||||
return { root, db, files, startup, platform, activity, guest, tmux, binding, losePrepare: () => { losePrepare = true; } };
|
||||
}
|
||||
it("stages all resolved startup inputs before native dispatch and binds separate work/activity connections", async () => {
|
||||
const f = await fixture(), role = join(f.root, "role.md"); writeFileSync(role, "Current nonce and useful task guidance");
|
||||
await f.startup.deliverStartup([{ path: "role.md", absolutePath: role, ownerRoot: f.root, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }], f.binding);
|
||||
const result = await f.startup.launch(f.binding, {}, f.tmux);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(f.files.get("/sandbox/openrig/work/.openrig/startup/role.md")!.toString()).toBe("Current nonce and useful task guidance");
|
||||
expect(f.files.get("/sandbox/openrig/work/CLAUDE.md")!.toString()).toContain("Read /sandbox/openrig/work/.openrig/startup/role.md");
|
||||
expect(f.activity).toHaveBeenCalledWith({ nodeId: f.binding.nodeId, generation: "generation-one", seat: "team-worker@startup-fixture", guestBaseUrl: "http://guest.test:7433" });
|
||||
const coordination = f.files.get("/sandbox/openrig/control/coordination.json")!.toString();
|
||||
expect(coordination).toContain("fixture-work-token"); expect(coordination).not.toContain("scoped-fixture-token");
|
||||
const receipt = f.db.prepare("SELECT receipt_json FROM environment_operations WHERE kind='launch'").get() as { receipt_json: string };
|
||||
expect(receipt.receipt_json).toContain("grant-one"); expect(receipt.receipt_json).not.toContain("fixture-token");
|
||||
expect(f.tmux.sendShellCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("reconciles a lost latch reply without issuing another grant or rewriting an execution", async () => {
|
||||
const f = await fixture(); f.losePrepare();
|
||||
await expect(f.startup.launch(f.binding, {}, f.tmux)).rejects.toThrow("Lost preparation reply");
|
||||
const before = sha256(f.files.get("/sandbox/openrig/control/launch.pending.json")!);
|
||||
expect((await f.startup.launch(f.binding, {}, f.tmux)).ok).toBe(true);
|
||||
expect(f.activity).toHaveBeenCalledTimes(1); expect(f.guest.mock.calls.filter(call => call[1][0] === "--prepare")).toHaveLength(1);
|
||||
expect(sha256(f.files.get("/sandbox/openrig/control/launch.pending.json")!)).toBe(before);
|
||||
});
|
||||
it("blocks a changed required source before issuing a grant or preparing native", async () => {
|
||||
const f = await fixture(), role = join(f.root, "role.md"); writeFileSync(role, "selected source");
|
||||
await f.startup.deliverStartup([{ path: "role.md", absolutePath: role, ownerRoot: f.root, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }], f.binding);
|
||||
writeFileSync(role, "changed after resolution");
|
||||
await expect(f.startup.launch(f.binding, {}, f.tmux)).rejects.toMatchObject({ code: "environment_projection_source_changed" });
|
||||
expect(f.activity).not.toHaveBeenCalled(); expect(f.guest).not.toHaveBeenCalled(); expect(f.tmux.sendShellCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses an exact-resume request without launching a fresh native", async () => {
|
||||
const f = await fixture(), result = await f.startup.launch(f.binding, { resumeToken: "saved-conversation" }, f.tmux);
|
||||
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
|
||||
expect(f.activity).not.toHaveBeenCalled(); expect(f.tmux.sendShellCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
|
||||
import { nodeAccountHomeSchema } from "../../src/db/migrations/141_node_account_home.js";
|
||||
import { escalationTargetRefsSchema } from "../../src/db/migrations/126_escalation_target_refs.js";
|
||||
import { nodeClaudePermissionsSchema } from "../../src/db/migrations/139_node_claude_permissions.js";
|
||||
@@ -153,7 +155,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { OpenShellDriver } from "../src/adapters/openshell-driver.js";
|
||||
import { sha256, type Placement, type EnvironmentBinding } from "../src/domain/environment-platform.js";
|
||||
const cleanup: Array<() => void> = [];
|
||||
afterEach(() => { for (const close of cleanup.splice(0)) close(); });
|
||||
it("reads pinned top-level list labels and treats pagination as incomplete", async () => {
|
||||
const command = vi.fn(async () => JSON.stringify({ next_page_token: "second-page", sandboxes: [{ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready", host_key_fingerprint: "fingerprint", labels: { openrigoperation: "operation-one" } }] }));
|
||||
const result = await new OpenShellDriver(command).list({ gateway: "gateway", workspace: "workspace" } as Placement);
|
||||
expect(result).toEqual({ complete: false, resources: [{ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready", fingerprint: "fingerprint", operationId: "operation-one" }] });
|
||||
expect(command.mock.calls[0][0]).toEqual(["--gateway", "gateway", "--workspace", "workspace", "sandbox", "list", "--output", "json", "--page-size", "100"]);
|
||||
});
|
||||
it("refuses changed provider policy bytes before a create command", async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-policy-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const policy = join(root, "policy.yaml"); writeFileSync(policy, "changed policy");
|
||||
const command = vi.fn(async () => "{}");
|
||||
await expect(new OpenShellDriver(command).create({ operationId: "operation-one", placement: { gateway: "g", workspace: "w", name: "worker-one", image: "image", provider: "provider", policy, policySha256: sha256("selected policy") } })).rejects.toMatchObject({ code: "environment_policy_changed" });
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves a typed missing-file result from the fixed guest helper", async () => {
|
||||
const command = vi.fn(async (args: string[]) => args.includes("get")
|
||||
? JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready", labels: {} })
|
||||
: JSON.stringify({ ok: false, code: "environment_file_missing" }));
|
||||
const binding = { guestRoot: "/sandbox/openrig", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding;
|
||||
await expect(new OpenShellDriver(command).read(binding, "/sandbox/openrig/work/role.md")).rejects.toMatchObject({ code: "environment_file_missing" });
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user