diff --git a/packages/cli/src/commands/environment.ts b/packages/cli/src/commands/environment.ts index fdbab5fd8..2c0b8866e 100644 --- a/packages/cli/src/commands/environment.ts +++ b/packages/cli/src/commands/environment.ts @@ -110,7 +110,7 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command { .requiredOption("--seat
").option("--node ", "Existing node ID; otherwise claimed by the exact named seat on its first launch") .requiredOption("--gateway ").requiredOption("--workspace ").requiredOption("--name ") .requiredOption("--image ").requiredOption("--provider ").requiredOption("--policy ") - .requiredOption("--guest-base-url ", "Guest-reachable daemon origin for the single P6 activity grant") + .requiredOption("--guest-base-url ", "Guest-reachable guest-only activity listener origin (not the main daemon API)") .option("--operation ", "Stable effect identity (default verb plus binding ID)").option("--json"); if (verb === "attach") sub.requiredOption("--resource-id ", "Immutable live resource ID").option("--fingerprint ", "Expected host-key fingerprint"); sub.action(action(async (binding: string, opts: Record) => { @@ -160,10 +160,11 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command { .option("--json").description("Select finite completion for one task, generation and required verified export; persistent defaults stay unchanged") .action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post>(`/api/environments/${encodeURIComponent(id)}/completion`, JSON.parse(readFileSync(opts.request, "utf8"))))))); cmd.command("effect").argument("").requiredOption("--effect ", "close-viewer, stop-native, stop-compute or delete-owned") - .requiredOption("--generation ", "Selected execution generation").requiredOption("--operation ", "Stable effect identity; retries reconcile without another provider mutation") + .option("--generation ", "Selected execution generation; omit only for delete-owned before any generation exists") + .option("--resource-id ", "Exact immutable compute ID; required for delete-owned without a generation").requiredOption("--operation ", "Stable effect identity; retries reconcile without another provider mutation") .option("--engine-socket ", "Owned local rootless Podman socket, required to capture dependent identities before delete-owned").option("--json") .description("Execute one selected effect and return its separate observed outcome; attached native/storage/compute are preserved") - .action(action(async (id: string, opts: { effect: string; generation: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation, operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 }))))); - cmd.addHelpText("after", "\nStart with rig environment setup --help. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n"); + .action(action(async (id: string, opts: { effect: string; generation?: string; resourceId?: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation ?? null, ...(opts.resourceId ? { resourceId: opts.resourceId } : {}), operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 }))))); + cmd.addHelpText("after", "\nStart with rig environment setup --help. Run from your managed seat, or set OPENRIG_SESSION_NAME to the acting member@rig in a plain shell for existing transport attribution (not authentication). --guest-base-url names the separate guest-only activity listener, reachable from the guest; never the main API. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n"); return cmd; } diff --git a/packages/cli/test/environment-effect.test.ts b/packages/cli/test/environment-effect.test.ts new file mode 100644 index 000000000..a2a5f41af --- /dev/null +++ b/packages/cli/test/environment-effect.test.ts @@ -0,0 +1,23 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { environmentCommand } from "../src/commands/environment.js"; +import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js"; +import type { EnvironmentCommandDeps } from "../src/commands/environment.js"; + +afterEach(() => vi.restoreAllMocks()); +it.each([undefined, "generation-one"])("sends the explicit cleanup selection with generation %s", async generation => { + const lifecycleDeps: LifecycleDeps = { + spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true), + readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null), + writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE), + mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true), + }; + const post = vi.fn(async () => ({ status: 200, data: { state: "confirmed" } })); + const deps: EnvironmentCommandDeps = { lifecycleDeps, clientFactory: () => ({ post }) as never }; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + await environmentCommand(deps).parseAsync(["node", "rig", "effect", "binding-one", "--effect", "delete-owned", "--operation", "delete-one", + ...(generation ? ["--generation", generation] : ["--resource-id", "compute-one"]), "--engine-socket", "/owned/engine.sock", "--json"]); + expect(post).toHaveBeenCalledWith("/api/environments/binding-one/effect", { + effect: "delete-owned", generation: generation ?? null, ...(generation ? {} : { resourceId: "compute-one" }), operationId: "delete-one", engineSocket: "/owned/engine.sock", + }, { timeoutMs: 120_000 }); + expect(log).toHaveBeenCalled(); +}); diff --git a/packages/daemon/assets/openshell/README.md b/packages/daemon/assets/openshell/README.md index 5de55e793..a1df4d626 100644 --- a/packages/daemon/assets/openshell/README.md +++ b/packages/daemon/assets/openshell/README.md @@ -6,6 +6,8 @@ The image recipe is an input recipe, not a published prebuilt image or a reprodu ## Install the reference and work connection +Run the environment commands from your managed OpenRig seat. In a plain shell, set `OPENRIG_SESSION_NAME` to the actual acting `member@rig` address, for example `export OPENRIG_SESSION_NAME=operator@kernel` when operating as that member. The CLI uses this existing convention to stamp `X-OpenRig-Session`; it attributes requests within the daemon's trusted transport, and is not authentication. The work and activity credentials below are separate authorities, not replacements for this actor attribution. + 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 @@ -15,7 +17,7 @@ rig environment setup --directory ./openshell-example --binding worker-one \ --guest-base-url http://host.openshell.internal:7782 --json ``` -Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The listener serves only `POST /api/activity/hooks` and the three permission-request routes (`POST /api/activity/permission-requests`, `GET /api/activity/permission-requests/` and `POST /api/activity/permission-requests//withdraw`), using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential. +Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The bind host must be an address on the daemon host that the guest can actually reach; a listener bound only to `127.0.0.1` is not reachable through a host alias that maps to a different interface. Set `OPENRIG_GUEST_ACTIVITY_HOST` to that selected host address and use its guest-visible name in `--guest-base-url`. With rootless networking, verify the host alias's route rather than assuming it reaches loopback. Bind the MCP listener to its separately selected guest-reachable address as well. The listener serves only `POST /api/activity/hooks` and the three permission-request routes (`POST /api/activity/permission-requests`, `GET /api/activity/permission-requests/` and `POST /api/activity/permission-requests//withdraw`), using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential. Import `claude-code-provider.yaml` using OpenShell's provider-profile commands, then create a provider with its `ANTHROPIC_API_KEY` environment lookup. See `openshell provider --help`; keep the master credential in provider custody, never an argument or source file. `create` passes that provider reference with `--no-auto-providers`. The guest helper saves only the provider-injected reference in a private file and uses Claude's `apiKeyHelper`. It does not copy an owner authentication home. @@ -82,6 +84,15 @@ rig environment effect worker-one --effect stop-compute --generation YOUR_OCCUPA rig environment effect worker-one --effect delete-owned --generation YOUR_OCCUPANT_GENERATION --operation delete-one --engine-socket /run/user/YOUR_UID/podman/podman.sock --json ``` +If create succeeded but projection failed before any execution generation was established, inspect the binding and select its exact immutable resource ID instead of inventing a generation: + +```sh +rig environment inspect worker-one --json +rig environment effect worker-one --effect delete-owned --resource-id YOUR_IMMUTABLE_RESOURCE_ID --operation delete-unstarted-one --engine-socket /run/user/YOUR_UID/podman/podman.sock --json +``` + +Omitting `--generation` is supported only for `delete-owned` with an explicit `--resource-id` while the binding still has no generation. A newly established generation or changed resource refuses this selection; inspect again and select the actual generation. Attached resources remain preserved. + Use the actual local rootless Podman socket on the provider host. The observer is read-only; it never removes engine resources itself. Before deletion it records exact workload/supervisor container identities and their attributable workspace/control/channel volumes. After the single vendor request, a complete gateway listing must establish the immutable resource's absence, and independent queries on that same engine/socket must establish those components absent. Other engine resources are preserved. Transport/auth errors, changed sockets, incomplete lists or a remaining volume stay unconfirmed. A vendor Accepted/Completed response alone does not establish engine cleanup. Per-sandbox secret cleanup is not an observed component class here. Attached consumers may close only their own viewer. Their external native process, storage and compute remain preserved. `rig seat stop` and `rig down` retain their existing pane semantics and label community panes accordingly; neither stands in for these explicit resource receipts. A pending/unconfirmed effect retains its original operation. Repeating that operation observes its result without blindly repeating stop/delete. A reused name with another immutable ID refuses deletion. diff --git a/packages/daemon/assets/openshell/guest.py b/packages/daemon/assets/openshell/guest.py index 12d8fad26..1be0ab7aa 100644 --- a/packages/daemon/assets/openshell/guest.py +++ b/packages/daemon/assets/openshell/guest.py @@ -55,9 +55,11 @@ def safe(root, raw): def regular(root, raw, limit=LIMIT): path = safe(root, raw) parts = path.relative_to(root).parts - directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + directory = None try: - for part in (*Path(root).parts[1:], *parts[:-1]): + # Ancestors need traversal, not a directory-read grant. Anchor reads at the selected root. + directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + for part in parts[:-1]: child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory) os.close(directory) directory = child @@ -79,7 +81,8 @@ def regular(root, raw, limit=LIMIT): except OSError: raise Refused("environment_file_invalid") finally: - os.close(directory) + if directory is not None: + os.close(directory) def atomic(root, raw, data): diff --git a/packages/daemon/assets/openshell/rig.yaml b/packages/daemon/assets/openshell/rig.yaml index 7d81cef01..5d137938d 100644 --- a/packages/daemon/assets/openshell/rig.yaml +++ b/packages/daemon/assets/openshell/rig.yaml @@ -2,6 +2,7 @@ version: "0.2" name: openshell-example pods: - id: team + label: Team members: - id: coordinator agent_ref: local:agents/coordinator @@ -13,7 +14,7 @@ pods: runtime: openshell-claude profile: default cwd: . -edges: - - kind: delegates_to - from: team.coordinator - to: team.worker + edges: + - kind: delegates_to + from: coordinator + to: worker diff --git a/packages/daemon/src/adapters/openshell-driver.ts b/packages/daemon/src/adapters/openshell-driver.ts index 1b3ff5906..f60f32007 100644 --- a/packages/daemon/src/adapters/openshell-driver.ts +++ b/packages/daemon/src/adapters/openshell-driver.ts @@ -106,11 +106,12 @@ export class OpenShellDriver implements EnvironmentDriver { if (beforeDispatch && !beforeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected stop is no longer eligible; no command was dispatched"); await this.command([...this.prefix(binding.placement), "stop", binding.placement.name]); } - async deleteEnvironment(binding: EnvironmentBinding, _operationId: string): Promise { + async deleteEnvironment(binding: EnvironmentBinding, _operationId: string, beforeDispatch?: () => boolean): Promise { const actual = await this.inspect(binding); if (!binding.resource || actual.id !== binding.resource.id || actual.name !== binding.resource.name || actual.workspace !== binding.resource.workspace || binding.resource.fingerprint && actual.fingerprint !== binding.resource.fingerprint) throw new EnvironmentError("environment_identity_changed", "The selected compute changed before deletion"); + if (beforeDispatch && !beforeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected delete is no longer eligible; no command was dispatched"); await this.command([...this.prefix(binding.placement), "delete", binding.placement.name]); } connectArgs(binding: EnvironmentBinding): string[] { return [...this.prefix(binding.placement), "connect", binding.placement.name]; } diff --git a/packages/daemon/src/domain/environment-lifecycle.ts b/packages/daemon/src/domain/environment-lifecycle.ts index f8316455c..00aed2fe7 100644 --- a/packages/daemon/src/domain/environment-lifecycle.ts +++ b/packages/daemon/src/domain/environment-lifecycle.ts @@ -12,7 +12,7 @@ export type EnvironmentLifecycleDriver = EnvironmentDriver & { nativeIdentity?(binding: EnvironmentBinding): Promise; stopNative?(binding: EnvironmentBinding, native: NativeObservation, operationId: string, beforeDispatch?: () => boolean): Promise; stopCompute?(binding: EnvironmentBinding, operationId: string, beforeDispatch?: () => boolean): Promise; - deleteEnvironment?(binding: EnvironmentBinding, operationId: string): Promise; + deleteEnvironment?(binding: EnvironmentBinding, operationId: string, beforeDispatch?: () => boolean): Promise; captureCleanup?(binding: EnvironmentBinding, socket: string): Promise; cleanupState?(binding: EnvironmentBinding, selection?: PodmanCleanupSelection): Promise; checkpointFiles?(binding: EnvironmentBinding, native: NativeObservation): Promise>; @@ -22,7 +22,7 @@ export type EnvironmentCheckpoint = { nativeType: "community:openshell-claude"; nativeId: string; predecessorResourceId: string; destinationCwd: string; nativeState: string; artifacts: Array<{ path: string; source: string; sha256: string; bytes: number; taskId: string }>; files: Array<{ path: string; source: string; sha256: string; bytes: number }>; }; -export type EffectReceipt = { version: 1; operationId: string; bindingId: string; generation: string; resourceId: string; +export type EffectReceipt = { version: 1; operationId: string; bindingId: string; generation: string | null; resourceId: string; effect: EnvironmentEffect; state: "confirmed" | "pending" | "unconfirmed"; remaining?: DependentObservation; detail?: string }; type Operation = { binding_id: string; kind: string; request_json: string; state: string; receipt_json: string | null; effect_id: string | null }; const stable = (v: unknown): string => JSON.stringify(v, (_key, x) => x && typeof x === "object" && !Array.isArray(x) @@ -49,6 +49,14 @@ export class EnvironmentLifecycle { if (binding.generation !== generation) refuse("environment_generation_changed", "The selected execution generation changed"); return binding; } + private cleanupSelection(id: string, resourceId: unknown): EnvironmentBinding { + identifier(resourceId, "resourceId"); + const binding = this.platform.get(id); + if (!binding?.resource) refuse("environment_binding_missing", "A confirmed compute binding is required"); + if (binding.generation !== null) refuse("environment_generation_changed", "An execution generation now exists; select it explicitly"); + if (binding.resource.id !== resourceId) refuse("environment_identity_changed", "The selected cleanup resource changed"); + return binding; + } private operation(id: string): Operation | undefined { return this.platform.db.prepare("SELECT * FROM environment_operations WHERE operation_id = ?").get(id) as Operation | undefined; } private intent(binding: EnvironmentBinding, id: string, kind: string, request: unknown): Operation { identifier(id, "operationId"); @@ -73,9 +81,11 @@ export class EnvironmentLifecycle { if (!native || native.nativeType !== "community:openshell-claude" || !/^[0-9a-f]{8}-[0-9a-f-]{27}$/.test(native.nativeId)) refuse("environment_native_unconfirmed", "The selected compute has no confirmed exact native identity"); return native; } - async effect(id: string, input: { operationId: string; generation: string; effect: EnvironmentEffect; engineSocket?: string; requestedBy?: string }, beforeDispatch?: () => boolean): Promise { + async effect(id: string, input: { operationId: string; generation: string | null; resourceId?: string; effect: EnvironmentEffect; engineSocket?: string; requestedBy?: string }, beforeDispatch?: () => boolean): Promise { if (!["close-viewer", "stop-native", "stop-compute", "delete-owned"].includes(input.effect)) refuse("environment_effect_unsupported", "Select close-viewer, stop-native, stop-compute or delete-owned", 400); - const binding = this.selected(id, input.generation); + const select = () => input.effect === "delete-owned" && input.generation === null + ? this.cleanupSelection(id, input.resourceId) : this.selected(id, identifier(input.generation, "generation")); + const binding = select(); if (input.effect !== "close-viewer" && binding.ownership !== "owned") refuse("environment_attached_preserved", "Attached native, storage and compute are preserved; close only this consumer's viewer"); const request = { ...input, resourceId: binding.resource!.id }, op = this.intent(binding, input.operationId, "effect", request); if (op.receipt_json) return JSON.parse(op.receipt_json); @@ -119,8 +129,9 @@ export class EnvironmentLifecycle { const found = listed.resources.find(row => row.id === binding.resource!.id); if (found) { this.platform.assertResource(binding, found); - this.selected(id, input.generation); - if (this.claim(input.operationId)) { try { await this.driver.deleteEnvironment(binding, op.effect_id!); } catch { /* observe original effect, never repeat */ } } + select(); + if (!await dispatch(allowed => this.driver.deleteEnvironment!(binding, op.effect_id!, allowed))) + return result("pending", { detail: "The selection changed before dispatch; no delete was sent" }); listed = await this.driver.list(binding.placement); } if (!listed.complete || listed.resources.some(row => row.id === binding.resource!.id || row.name === binding.resource!.name)) return result("pending", { detail: "Exact provider absence is not confirmed" }); diff --git a/packages/daemon/test/environment-lifecycle.test.ts b/packages/daemon/test/environment-lifecycle.test.ts index 2c5388f4d..0dc7e42dc 100644 --- a/packages/daemon/test/environment-lifecycle.test.ts +++ b/packages/daemon/test/environment-lifecycle.test.ts @@ -11,7 +11,7 @@ import { createFullTestDb, createTestApp } from "./helpers/test-app.js"; const cleanup: Array<() => void> = []; afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); }); -async function fixture(attached = false, automatic = true) { +async function fixture(attached = false, automatic = true, beforeGeneration = 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" }; @@ -40,16 +40,82 @@ async function fixture(attached = false, automatic = true) { 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 }; }; 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 input = { version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: beforeGeneration ? undefined : node.id, seat: "worker@lifecycle", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7433", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } }; const created = await request(attached ? "/attach" : "/create", { ...input, ...(attached ? { resourceId: resource.id } : {}) }); expect(created.status, JSON.stringify(created.body)).toBeLessThan(300); + if (!beforeGeneration) { db.prepare("UPDATE environment_bindings SET generation='generation-one' WHERE binding_id='binding-one'").run(); const receipt = { version: 1, bindingId: "binding-one", nodeId: node.id, resourceId: resource.id, ...native, activityGrantId: "grant-one" }; db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state,receipt_json) VALUES ('launch-generation-one','binding-one','launch',?,'confirmed',?)").run(JSON.stringify(receipt), JSON.stringify(receipt)); + } const effect = (name: string, operationId = "effect-one") => request("/binding-one/effect", { operationId, generation: "generation-one", effect: name }); const lifecycle = new EnvironmentLifecycle(new EnvironmentPlatform(db, driver), queue, join(root, "retained")); return { root, db, driver, queue, lifecycle, request, effect, resource, native, files, history, capture: () => f.snapshotCapture.captureSnapshot(rig.id, "manual"), disappear: () => { present = false; }, counts: () => ({ stopCalls, deleteCalls, creates }), loseStop: () => { loseStop = true; }, unavailable: () => { unavailable = true; }, dirtyCleanup: () => { dependents.volumes = true; } }; } describe("owned and attached environment effects", () => { + async function failedProjection(attached = false) { + const f = await fixture(attached, true, true), platform = new EnvironmentPlatform(f.db, f.driver); + vi.spyOn(f.driver, "read").mockRejectedValueOnce(new Error("guest root read denied")); + const source = join(f.root, "policy.yaml"); + await expect(platform.project("binding-one", { operationId: "projection-failed", generation: "not-established", files: [ + { path: "work/role.md", source, sha256: sha256(readFileSync(source)), required: true }, + ] })).rejects.toThrow("guest root read denied"); + expect(platform.get("binding-one")).toMatchObject({ generation: null, resource: { id: f.resource.id } }); + expect(platform.get("binding-one")!.nodeId).toBeUndefined(); + return { ...f, cleanup: () => f.request("/binding-one/effect", { operationId: "cleanup-failed-create", effect: "delete-owned", generation: null, resourceId: f.resource.id }) }; + } + it("cleans the exact owned create after projection fails before node and generation binding", async () => { + const f = await failedProjection(), result = await f.cleanup(); + expect(result.status, JSON.stringify(result.body)).toBe(200); + expect(result.body).toMatchObject({ state: "confirmed", generation: null, resourceId: "compute-one", effect: "delete-owned" }); + expect(f.counts()).toMatchObject({ creates: 1, deleteCalls: 1, stopCalls: 0 }); + expect((await f.cleanup()).body).toEqual(result.body); expect(f.counts().deleteCalls).toBe(1); + }); + it("reconciles uncertain pre-generation cleanup without another provider delete", async () => { + const f = await failedProjection(); + let observe = false; + vi.spyOn(f.driver, "deleteEnvironment").mockImplementationOnce(async () => { f.disappear(); throw new Error("lost delete reply"); }); + const original = f.driver.cleanupState; + vi.spyOn(f.driver, "cleanupState").mockImplementation(async () => { if (!observe) throw new Error("engine unavailable"); return original(); }); + expect((await f.cleanup()).body.state).toBe("unconfirmed"); + observe = true; + expect((await f.cleanup()).body.state).toBe("confirmed"); + expect(f.driver.deleteEnvironment).toHaveBeenCalledTimes(1); + }); + it("preserves an attached resource after projection fails before a generation exists", async () => { + const f = await failedProjection(true), result = await f.cleanup(); + expect(result.status).toBe(409); expect(result.body.code).toBe("environment_attached_preserved"); + expect(f.counts()).toMatchObject({ creates: 0, deleteCalls: 0, stopCalls: 0 }); + }); + + it("requires the exact resource for pre-generation cleanup and never infers a native generation", async () => { + const f = await failedProjection(); + for (const body of [ + { effect: "delete-owned", generation: null }, + { effect: "delete-owned", generation: null, resourceId: "another-resource" }, + { effect: "stop-compute", generation: null, resourceId: f.resource.id }, + ]) { + const result = await f.request("/binding-one/effect", { operationId: "bad-selection", ...body }); + expect(result.status).toBeGreaterThanOrEqual(400); + } + expect(f.counts()).toMatchObject({ deleteCalls: 0, stopCalls: 0 }); + }); + it("refuses generation-free cleanup once a generation exists", async () => { + const f = await fixture(); + const result = await f.request("/binding-one/effect", { operationId: "bad-selection", effect: "delete-owned", generation: null, resourceId: f.resource.id }); + expect(result.status).toBe(409); expect(result.body.code).toBe("environment_generation_changed"); + expect(f.counts().deleteCalls).toBe(0); + }); + it("does not dispatch pre-generation deletion if projection establishes a generation during observation", async () => { + const f = await failedProjection(), list = f.driver.list; + vi.spyOn(f.driver, "list").mockImplementationOnce(async () => { + const result = await list(); + f.db.prepare("UPDATE environment_bindings SET generation='new-generation' WHERE binding_id='binding-one'").run(); + return result; + }); + const result = await f.cleanup(); + expect(result.status).toBe(409); expect(result.body.code).toBe("environment_generation_changed"); + expect(f.counts().deleteCalls).toBe(0); + }); 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"); diff --git a/packages/daemon/test/openshell-driver.test.ts b/packages/daemon/test/openshell-driver.test.ts index ee0370bcf..c87a1dcdf 100644 --- a/packages/daemon/test/openshell-driver.test.ts +++ b/packages/daemon/test/openshell-driver.test.ts @@ -77,3 +77,15 @@ it("rechecks dispatch permission after the driver's own identity await", async ( await entered; eligible = false; release(); await refused; expect(command.mock.calls.some(([args]) => args.includes("stop"))).toBe(false); }); + +it("rechecks cleanup selection after delete's identity await", async () => { + let eligible = true; + const command = vi.fn(async (args: string[]) => { + if (args.includes("get")) { eligible = false; return JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready" }); } + return ""; + }); + const binding = { placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding; + await expect(new OpenShellDriver(command).deleteEnvironment(binding, "cleanup-one", () => eligible)) + .rejects.toMatchObject({ code: "environment_selection_changed" }); + expect(command.mock.calls.some(([args]) => args.includes("delete"))).toBe(false); +}); diff --git a/packages/daemon/test/openshell-example.test.ts b/packages/daemon/test/openshell-example.test.ts new file mode 100644 index 000000000..f311f09bd --- /dev/null +++ b/packages/daemon/test/openshell-example.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { RigSpecCodec } from "../src/domain/rigspec-codec.js"; +import { RigSpecSchema } from "../src/domain/rigspec-schema.js"; +import { validateRigSpecImport } from "../src/domain/spec-validation-service.js"; +import { createFullTestDb, createTestApp } from "./helpers/test-app.js"; + +const sourceRef = resolve(import.meta.dirname, "../assets/openshell/rig.yaml"); +const yaml = readFileSync(sourceRef, "utf8"); +afterEach(() => vi.restoreAllMocks()); +it("the shipped OpenShell example parses and validates with its edge inside the pod", () => { + expect(validateRigSpecImport(yaml)).toMatchObject({ valid: true, errors: [] }); + const spec = RigSpecSchema.normalize(RigSpecCodec.parse(yaml) as Record); + expect(spec.pods[0]!.edges).toContainEqual({ kind: "delegates_to", from: "coordinator", to: "worker" }); + expect(spec.edges ?? []).toEqual([]); +}); +it("the ordinary up plan classifies the shipped example without launching seats", async () => { + const db = createFullTestDb(); + try { + const f = createTestApp(db, { upRouterFsOps: { + exists: (path: string) => path === sourceRef, + readFile: () => yaml, + readHead: (_path: string, bytes: number) => Buffer.from(yaml).subarray(0, bytes), + } }); + const plan = vi.spyOn(f.bootstrapOrchestrator, "bootstrap").mockResolvedValue({ runId: "plan", status: "planned", stages: [], errors: [], warnings: [] } as never); + const result = await f.app.request("/api/up", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceRef, plan: true }) }); + expect(result.status, await result.text()).toBe(200); + expect(plan).toHaveBeenCalledWith(expect.objectContaining({ mode: "plan", sourceRef })); + expect(f.tmuxAdapter.createSession).not.toHaveBeenCalled(); + } finally { db.close(); } +}); diff --git a/packages/daemon/test/openshell-guest.test.ts b/packages/daemon/test/openshell-guest.test.ts index cc2119b29..5f94ded4d 100644 --- a/packages/daemon/test/openshell-guest.test.ts +++ b/packages/daemon/test/openshell-guest.test.ts @@ -58,3 +58,33 @@ it("the shipped resume latch refuses missing exact history before provider prepa writeFileSync(join(root,"control","launch.pending.json"),JSON.stringify({version:1,bindingId:"binding-one",generation:"generation-two",nativeId:id,resumeToken:id,nativeType:"community:openshell-claude",files:[],checkpoint:{nativeId:id,nativeType:"community:openshell-claude",files:[]}})); expect(await command(root,"--prepare")).toMatchObject({ok:false,code:"environment_checkpoint_invalid"}); }); + +// Model the measured filesystem grant: the selected root is readable, its ancestors are not. +it("reads inside the granted workspace when opening every ancestor is denied", async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-granted-root-"))); roots.push(root); + mkdirSync(join(root, "work")); const file = join(root, "work", "role.md"); writeFileSync(file, "selected context"); + const script = `import importlib.util,os,sys +from pathlib import Path +spec=importlib.util.spec_from_file_location("guest",sys.argv[1]); g=importlib.util.module_from_spec(spec); spec.loader.exec_module(g) +original=os.open +ancestors={str(p) for p in Path(sys.argv[2]).parents} +def granted(path, flags, *args, **kwargs): + if str(path) in ancestors: raise PermissionError(13,"ancestor not granted",str(path)) + return original(path,flags,*args,**kwargs) +os.open=granted +try: + print(g.regular(sys.argv[2],sys.argv[3]).decode()) +except Exception as error: print(type(error).__name__+":"+str(error)) +`; + const result = await run("python3", ["-c", script, helper, root, file], { timeout: 5000 }); + expect(result.stdout.trim()).toBe("selected context"); +}); + +it("preserves rejection of a symlink in the selected root's ancestors", async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-root-link-"))); roots.push(root); + mkdirSync(join(root, "real")); mkdirSync(join(root, "real", "workspace")); + writeFileSync(join(root, "real", "workspace", "role.md"), "preserved"); + symlinkSync(join(root, "real"), join(root, "link")); + expect(await command(join(root, "link", "workspace"), "--read", "role.md")) + .toMatchObject({ ok: false, code: "environment_root_invalid" }); +});