fix: qualify OpenShell E1 patch fit on the selected release base

This commit is contained in:
dev-driver committed 2026-11-02 04:18:18 +00:00
1 parent 16121548c2
commit 12df562ceb
11 files changed
+212 -21

No files matched your search

+5 -4
View File
@@ -110,7 +110,7 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command {
.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")
.requiredOption("--guest-base-url <origin>", "Guest-reachable guest-only activity listener origin (not the main daemon API)")
.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>) => {
@@ -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<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/completion`, JSON.parse(readFileSync(opts.request, "utf8")))))));
cmd.command("effect").argument("<binding>").requiredOption("--effect <name>", "close-viewer, stop-native, stop-compute or delete-owned")
.requiredOption("--generation <id>", "Selected execution generation").requiredOption("--operation <id>", "Stable effect identity; retries reconcile without another provider mutation")
.option("--generation <id>", "Selected execution generation; omit only for delete-owned before any generation exists")
.option("--resource-id <id>", "Exact immutable compute ID; required for delete-owned without a generation").requiredOption("--operation <id>", "Stable effect identity; retries reconcile without another provider mutation")
.option("--engine-socket <path>", "Owned local rootless Podman socket, required to capture dependent identities before delete-owned").option("--json")
.description("Execute one selected effect and return its separate observed outcome; attached native/storage/compute are preserved")
.action(action(async (id: string, opts: { effect: string; generation: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation, operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 })))));
cmd.addHelpText("after", "\nStart with rig environment setup --help. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n");
.action(action(async (id: string, opts: { effect: string; generation?: string; resourceId?: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation ?? 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;
}
@@ -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();
});
+12 -1
View File
@@ -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/<askId>` and `POST /api/activity/permission-requests/<askId>/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/<askId>` and `POST /api/activity/permission-requests/<askId>/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.
+6 -3
View File
@@ -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):
+5 -4
View File
@@ -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
@@ -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<void> {
async deleteEnvironment(binding: EnvironmentBinding, _operationId: string, beforeDispatch?: () => boolean): Promise<void> {
const actual = await this.inspect(binding);
if (!binding.resource || actual.id !== binding.resource.id || actual.name !== binding.resource.name || actual.workspace !== binding.resource.workspace
|| binding.resource.fingerprint && actual.fingerprint !== binding.resource.fingerprint)
throw new EnvironmentError("environment_identity_changed", "The selected compute changed before deletion");
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]; }
@@ -12,7 +12,7 @@ export type EnvironmentLifecycleDriver = EnvironmentDriver & {
nativeIdentity?(binding: EnvironmentBinding): Promise<NativeObservation>;
stopNative?(binding: EnvironmentBinding, native: NativeObservation, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
stopCompute?(binding: EnvironmentBinding, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
deleteEnvironment?(binding: EnvironmentBinding, operationId: string): Promise<void>;
deleteEnvironment?(binding: EnvironmentBinding, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
captureCleanup?(binding: EnvironmentBinding, socket: string): Promise<PodmanCleanupSelection>;
cleanupState?(binding: EnvironmentBinding, selection?: PodmanCleanupSelection): Promise<DependentObservation>;
checkpointFiles?(binding: EnvironmentBinding, native: NativeObservation): Promise<Array<{ path: string; sha256: string; bytes: number }>>;
@@ -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<EffectReceipt> {
async effect(id: string, input: { operationId: string; generation: string | null; resourceId?: string; effect: EnvironmentEffect; engineSocket?: string; requestedBy?: string }, beforeDispatch?: () => boolean): Promise<EffectReceipt> {
if (!["close-viewer", "stop-native", "stop-compute", "delete-owned"].includes(input.effect)) refuse("environment_effect_unsupported", "Select close-viewer, stop-native, stop-compute or delete-owned", 400);
const binding = this.selected(id, input.generation);
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" });
@@ -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<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 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");
@@ -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);
});
@@ -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<string, unknown>);
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(); }
});
@@ -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" });
});