mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix: qualify OpenShell E1 patch fit on the selected release base
This commit is contained in:
1 parent
16121548c2
commit
12df562ceb
11 files changed
+212
-21
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
Reference in new issue
Block a user