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" });
+});
]