mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat: surface generation-scoped permission drift
This commit is contained in:
1 parent
3614a956ba
commit
e0764328ab
42 files changed
+1275
-83
No files matched your search
+117
-17
@@ -13,7 +13,36 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { DaemonClient } from "./client.js";
|
||||
import {
|
||||
DaemonClient,
|
||||
DaemonConnectionError,
|
||||
DaemonResponseError,
|
||||
DaemonTimeoutError,
|
||||
} from "./client.js";
|
||||
|
||||
interface FrontDoorPermissionDiagnostic {
|
||||
transport: { state: "healthy" | "connect" | "timeout" | "response" };
|
||||
cwdRead: { state: "visible" | "denied" | "unknown" };
|
||||
commandPath: { state: "available" | "missing" | "unknown" };
|
||||
enforcement: {
|
||||
axis: "permission" | "sandbox" | "resource_trust" | "not_applicable";
|
||||
state: "aligned" | "drift" | "unknown";
|
||||
expected: string | null;
|
||||
effective: string | { defaultMode: string } | null;
|
||||
sourcePath: string | null;
|
||||
reason?: string;
|
||||
};
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export type FrontDoorProbeResult =
|
||||
| { state: "ready" }
|
||||
| { state: "diagnostic"; diagnostic: FrontDoorPermissionDiagnostic }
|
||||
| { state: "connect" | "timeout" | "response"; message: string };
|
||||
|
||||
interface FrontDoorProbeClient {
|
||||
get(path: string, options?: { timeoutMs?: number }): Promise<{ status: number; data: unknown }>;
|
||||
}
|
||||
|
||||
/** Monorepo-first, bundled-fallback TUI entry resolution — the exact
|
||||
* resolveDaemonPath pattern (see daemon-lifecycle.ts): a dev checkout's
|
||||
@@ -44,28 +73,88 @@ export interface FrontDoorIo {
|
||||
out?: (line: string) => void;
|
||||
err?: (line: string) => void;
|
||||
exit?: (code: number) => void;
|
||||
/** fast daemon reachability probe (default: GET /healthz, 1.5s cap) */
|
||||
probeDaemon?: () => Promise<boolean>;
|
||||
/** Typed daemon/current-seat probe (legacy booleans remain accepted for injected callers). */
|
||||
probeDaemon?: () => Promise<boolean | FrontDoorProbeResult>;
|
||||
/** launch the TUI and resolve with its exit code */
|
||||
launchTui?: () => Promise<number>;
|
||||
}
|
||||
|
||||
async function defaultProbeDaemon(): Promise<boolean> {
|
||||
function envValue(env: NodeJS.ProcessEnv, current: string, legacy: string): string | undefined {
|
||||
return env[current]?.trim() || env[legacy]?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed seats request the strict current-seat diagnostic. Unmanaged shells
|
||||
* keep the cheap health-only path. Transport classes remain distinct.
|
||||
*/
|
||||
export async function probeFrontDoor(input: {
|
||||
client?: FrontDoorProbeClient;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): Promise<FrontDoorProbeResult> {
|
||||
const client = input.client ?? new DaemonClient(undefined, { timeoutMs: 1500 });
|
||||
const env = input.env ?? process.env;
|
||||
const nodeId = envValue(env, "OPENRIG_NODE_ID", "RIGGED_NODE_ID");
|
||||
const sessionName = envValue(env, "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const identityQuery = nodeId
|
||||
? `nodeId=${encodeURIComponent(nodeId)}`
|
||||
: sessionName
|
||||
? `sessionName=${encodeURIComponent(sessionName)}`
|
||||
: null;
|
||||
const path = identityQuery
|
||||
? `/api/whoami?${identityQuery}&compact=1&diagnostics=permission`
|
||||
: "/healthz";
|
||||
try {
|
||||
const client = new DaemonClient();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const res = await fetch(`${client.baseUrl}/healthz`, { signal: controller.signal });
|
||||
return res.ok;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
const res = await client.get(path, { timeoutMs: 1500 });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
return { state: "response", message: `daemon replied HTTP ${res.status}` };
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
if (!identityQuery) return { state: "ready" };
|
||||
const diagnostic = (res.data as { permissionDrift?: FrontDoorPermissionDiagnostic } | null)?.permissionDrift;
|
||||
if (!diagnostic) {
|
||||
return { state: "response", message: "current-seat diagnostic was absent from the daemon response" };
|
||||
}
|
||||
if (
|
||||
diagnostic.cwdRead.state === "visible"
|
||||
&& diagnostic.commandPath.state === "available"
|
||||
&& diagnostic.enforcement.state === "aligned"
|
||||
) {
|
||||
return { state: "ready" };
|
||||
}
|
||||
return { state: "diagnostic", diagnostic };
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonTimeoutError) return { state: "timeout", message: error.message };
|
||||
if (error instanceof DaemonResponseError) return { state: "response", message: error.message };
|
||||
if (error instanceof DaemonConnectionError) return { state: "connect", message: error.message };
|
||||
return { state: "response", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProbe(result: boolean | FrontDoorProbeResult): FrontDoorProbeResult {
|
||||
if (result === true) return { state: "ready" };
|
||||
if (result === false) return { state: "connect", message: "cannot connect to the OpenRig daemon" };
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderDiagnostic(diagnostic: FrontDoorPermissionDiagnostic): string {
|
||||
const label = diagnostic.enforcement.axis === "resource_trust"
|
||||
? "resource trust"
|
||||
: diagnostic.enforcement.axis;
|
||||
const effective = typeof diagnostic.enforcement.effective === "object" && diagnostic.enforcement.effective !== null
|
||||
? diagnostic.enforcement.effective.defaultMode
|
||||
: diagnostic.enforcement.effective;
|
||||
const parts = [
|
||||
`transport: ${diagnostic.transport.state}`,
|
||||
`cwd/read: ${diagnostic.cwdRead.state}`,
|
||||
`command/PATH: ${diagnostic.commandPath.state}`,
|
||||
`${label}: ${diagnostic.enforcement.state.toUpperCase()}`,
|
||||
];
|
||||
if (diagnostic.enforcement.expected) parts.push(`expected=${diagnostic.enforcement.expected}`);
|
||||
if (effective) parts.push(`effective=${effective}`);
|
||||
if (diagnostic.enforcement.sourcePath) parts.push(`source=${diagnostic.enforcement.sourcePath}`);
|
||||
if (diagnostic.enforcement.reason) parts.push(`reason=${diagnostic.enforcement.reason}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
async function defaultLaunchTui(): Promise<number> {
|
||||
const entry = resolveTuiPath(import.meta.dirname);
|
||||
if (!entry) throw new Error("mission-control TUI is not installed (no tui/dist/main.js next to this CLI)");
|
||||
@@ -85,13 +174,24 @@ async function defaultLaunchTui(): Promise<number> {
|
||||
export async function openMissionControl(io: FrontDoorIo = {}): Promise<void> {
|
||||
const err = io.err ?? ((l: string) => process.stderr.write(l + "\n"));
|
||||
const exit = io.exit ?? ((c: number) => process.exit(c));
|
||||
const probe = io.probeDaemon ?? defaultProbeDaemon;
|
||||
const probe = io.probeDaemon ?? probeFrontDoor;
|
||||
const launch = io.launchTui ?? defaultLaunchTui;
|
||||
|
||||
if (!(await probe())) {
|
||||
const probeResult = normalizeProbe(await probe());
|
||||
if (probeResult.state !== "ready") {
|
||||
for (const line of USAGE_LINES) err(line);
|
||||
err("");
|
||||
err("daemon not running — try: rig up (then bare `rig` opens mission control)");
|
||||
if (probeResult.state === "diagnostic") {
|
||||
const verdict = probeResult.diagnostic.enforcement.state === "drift"
|
||||
? "PERMISSION_DRIFT"
|
||||
: "UNKNOWN_EFFECTIVE";
|
||||
err(`runtime posture: ${verdict}`);
|
||||
err(renderDiagnostic(probeResult.diagnostic));
|
||||
} else if (probeResult.state === "connect") {
|
||||
err(`transport: connect · ${probeResult.message} · try: rig up`);
|
||||
} else {
|
||||
err(`transport: ${probeResult.state} · ${probeResult.message}`);
|
||||
}
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { runFrontDoor, resolveTuiPath, type FrontDoorIo } from "../src/front-door.js";
|
||||
import { runFrontDoor, resolveTuiPath, probeFrontDoor, type FrontDoorIo } from "../src/front-door.js";
|
||||
|
||||
function io(overrides: Partial<FrontDoorIo> = {}): FrontDoorIo & {
|
||||
outLines: string[];
|
||||
@@ -72,19 +72,62 @@ describe("bare-rig front door — ownership rules", () => {
|
||||
});
|
||||
|
||||
describe("bare-rig front door — first-impression degrade (never a stack trace)", () => {
|
||||
it("daemon down → helpful usage naming `rig up`, exit 1, launch NOT attempted", async () => {
|
||||
it("transport connect failure → typed helpful usage naming `rig up`, never generic daemon-down", async () => {
|
||||
const deps = io({ probeDaemon: async () => false });
|
||||
const handled = await runFrontDoor(["node", "rig"], deps);
|
||||
expect(handled).toBe(true);
|
||||
expect(deps.launches).toBe(0);
|
||||
const text = deps.errLines.join("\n");
|
||||
expect(text).toMatch(/daemon not running/i);
|
||||
expect(text).toMatch(/transport: connect/i);
|
||||
expect(text).toMatch(/rig up/);
|
||||
expect(text).toMatch(/rig --help/);
|
||||
expect(text).not.toMatch(/daemon not running/i);
|
||||
expect(text).not.toMatch(/\n\s+at /); // no stack frames
|
||||
expect(deps.exits).toEqual([1]);
|
||||
});
|
||||
|
||||
it("permission drift renders all four axes plus expected/effective/source and never launches", async () => {
|
||||
const deps = io({
|
||||
probeDaemon: async () => ({
|
||||
state: "diagnostic" as const,
|
||||
diagnostic: {
|
||||
transport: { state: "healthy" as const },
|
||||
cwdRead: { state: "visible" as const },
|
||||
commandPath: { state: "available" as const },
|
||||
enforcement: {
|
||||
axis: "permission" as const,
|
||||
state: "drift" as const,
|
||||
expected: "acceptEdits",
|
||||
effective: { defaultMode: "denyAll", allow: [], ask: [], deny: ["Read(/outside/**)"] },
|
||||
sourcePath: "/work/.claude/settings.local.json",
|
||||
},
|
||||
observedAt: "2026-08-08T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
});
|
||||
await runFrontDoor(["node", "rig"], deps);
|
||||
const text = deps.errLines.join("\n");
|
||||
expect(text).toContain("transport: healthy");
|
||||
expect(text).toContain("cwd/read: visible");
|
||||
expect(text).toContain("command/PATH: available");
|
||||
expect(text).toContain("permission: DRIFT");
|
||||
expect(text).toContain("expected=acceptEdits");
|
||||
expect(text).toContain("effective=denyAll");
|
||||
expect(text).toContain("source=/work/.claude/settings.local.json");
|
||||
expect(text).not.toMatch(/daemon not running/i);
|
||||
expect(deps.launches).toBe(0);
|
||||
expect(deps.exits).toEqual([1]);
|
||||
});
|
||||
|
||||
it.each(["timeout", "response"] as const)("%s stays a transport/response verdict and never becomes permission drift", async (state) => {
|
||||
const deps = io({ probeDaemon: async () => ({ state, message: `${state} detail` }) });
|
||||
await runFrontDoor(["node", "rig"], deps);
|
||||
const text = deps.errLines.join("\n");
|
||||
expect(text).toContain(`transport: ${state}`);
|
||||
expect(text).not.toContain("PERMISSION_DRIFT");
|
||||
expect(text).not.toMatch(/daemon not running/i);
|
||||
});
|
||||
|
||||
it("TUI init failure → helpful usage with the failure line, exit 1, no stack trace", async () => {
|
||||
const deps = io({
|
||||
launchTui: async () => {
|
||||
@@ -107,6 +150,44 @@ describe("bare-rig front door — first-impression degrade (never a stack trace)
|
||||
});
|
||||
});
|
||||
|
||||
describe("front-door probe routing", () => {
|
||||
it("uses explicit current-seat whoami diagnostics for a managed seat", async () => {
|
||||
const paths: string[] = [];
|
||||
const result = await probeFrontDoor({
|
||||
env: { OPENRIG_NODE_ID: "node/1" },
|
||||
client: {
|
||||
get: async (path: string) => {
|
||||
paths.push(path);
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
permissionDrift: {
|
||||
transport: { state: "healthy" },
|
||||
cwdRead: { state: "visible" },
|
||||
commandPath: { state: "available" },
|
||||
enforcement: { axis: "sandbox", state: "aligned", expected: "workspace-write", effective: "workspace-write", sourcePath: null },
|
||||
observedAt: "2026-08-08T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ state: "ready" });
|
||||
expect(paths).toEqual(["/api/whoami?nodeId=node%2F1&compact=1&diagnostics=permission"]);
|
||||
});
|
||||
|
||||
it("uses cheap health for an unmanaged shell", async () => {
|
||||
const paths: string[] = [];
|
||||
const result = await probeFrontDoor({
|
||||
env: {},
|
||||
client: { get: async (path: string) => { paths.push(path); return { status: 200, data: { status: "ok" } }; } },
|
||||
});
|
||||
expect(result).toEqual({ state: "ready" });
|
||||
expect(paths).toEqual(["/healthz"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTuiPath — monorepo-first, bundled fallback (the daemon resolver pattern)", () => {
|
||||
it("prefers the monorepo sibling dist, falls back to the bundled copy, null when neither", () => {
|
||||
const base = "/repo/packages/cli/dist";
|
||||
@@ -137,12 +218,12 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
|
||||
);
|
||||
expect(run.status).toBe(0);
|
||||
let text = "";
|
||||
for (let i = 0; i < 20 && !/daemon not running|Usage: rig/.test(text); i++) {
|
||||
for (let i = 0; i < 20 && !/transport: connect|Usage: rig/.test(text); i++) {
|
||||
spawnSync("sleep", ["0.5"]);
|
||||
text = spawnSync("tmux", ["capture-pane", "-t", session, "-p"], { encoding: "utf-8", timeout: 5000 }).stdout ?? "";
|
||||
}
|
||||
spawnSync("tmux", ["kill-session", "-t", session], { encoding: "utf-8" });
|
||||
expect(text).toMatch(/daemon not running — try: rig up/);
|
||||
expect(text).toMatch(/transport: connect/);
|
||||
expect(text).not.toMatch(/Usage: rig \[options\] \[command\]/); // NOT the commander bypass
|
||||
expect(text).not.toMatch(/\n\s+at /);
|
||||
});
|
||||
@@ -154,7 +235,7 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
|
||||
{ encoding: "utf-8", timeout: 10000, env: { ...process.env, OPENRIG_URL: "http://127.0.0.1:9" } },
|
||||
);
|
||||
const text = `${probe.stdout}${probe.stderr}`;
|
||||
expect(text).toMatch(/daemon not running — try: rig up/);
|
||||
expect(text).toMatch(/transport: connect/);
|
||||
expect(text).not.toMatch(/\n\s+at /);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,13 +24,14 @@ describe("rig tui — alias of the bare-rig front-door mission-control path (no
|
||||
expect(exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("daemon DOWN → identical friendly degrade (same USAGE + 'rig up' line), exit 1, no launch", async () => {
|
||||
it("transport connect failure → identical typed degrade, exit 1, no launch", async () => {
|
||||
const launchTui = vi.fn(async () => 0);
|
||||
const errs: string[] = [];
|
||||
const exit = vi.fn();
|
||||
await run({ stdoutIsTTY: true, probeDaemon: async () => false, launchTui, exit, err: (l: string) => errs.push(l) });
|
||||
expect(launchTui).not.toHaveBeenCalled();
|
||||
expect(errs.join("\n")).toMatch(/daemon not running — try: rig up/);
|
||||
expect(errs.join("\n")).toMatch(/transport: connect/);
|
||||
expect(errs.join("\n")).not.toMatch(/daemon not running/);
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
import { mergeManagedBlock } from "../domain/managed-blocks.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { validateClaudeActivityHookDelivery } from "../domain/claude-activity-hooks.js";
|
||||
import { observeClaudePermission } from "../domain/permission-drift.js";
|
||||
|
||||
export interface ClaudeAdapterFsOps {
|
||||
readFile(path: string): string;
|
||||
@@ -225,6 +226,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
// The SAME decision (claudePostureFlag) is used on the restore path (claude-resume.ts).
|
||||
// OPR.0.4.8.3 Seam B: a per-seat resolved policy posture (binding.launchPosture) overrides env.
|
||||
const permissionMode = claudePostureFlag(process.env, binding.launchPosture);
|
||||
const appliedLaunch = observeClaudePermission(permissionMode);
|
||||
|
||||
// 51-07: a per-agent model declared in the spec (member.model ?? profile ?? defaults, resolved
|
||||
// onto binding.model at instantiate) is emitted as `--model <x>` on the launch command. Absent →
|
||||
@@ -270,7 +272,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
error: `claude-code fork: could not capture new post-fork session id from claude session storage after ${FORK_POLL_ATTEMPTS} polls (${(FORK_POLL_ATTEMPTS * FORK_POLL_DELAY_MS) / 1000}s ceiling)`,
|
||||
};
|
||||
}
|
||||
return { ok: true, resumeToken: newToken, resumeType: "claude_id" };
|
||||
return { ok: true, resumeToken: newToken, resumeType: "claude_id", appliedLaunch };
|
||||
}
|
||||
|
||||
const generatedSessionId = opts.resumeToken ? null : this.sessionIdFactory();
|
||||
@@ -291,13 +293,13 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
if (opts.resumeToken) {
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession);
|
||||
if (!verification.ok) return verification;
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id" };
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch };
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: prefer an immediately discoverable persisted session,
|
||||
// but fall back to the UUID we assigned explicitly at launch time.
|
||||
const token = this.captureResumeToken(opts.name);
|
||||
return { ok: true, resumeToken: token ?? generatedSessionId ?? undefined, resumeType: "claude_id" };
|
||||
return { ok: true, resumeToken: token ?? generatedSessionId ?? undefined, resumeType: "claude_id", appliedLaunch };
|
||||
}
|
||||
|
||||
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { TmuxAdapter } from "./tmux.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { claudePostureFlag } from "./yolo-mode.js";
|
||||
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
|
||||
|
||||
export type ResumeResult =
|
||||
| { ok: true }
|
||||
| { ok: true; appliedLaunch?: AppliedLaunchObservation }
|
||||
// L3: `attention_required` is a non-terminal failure — Claude is alive and
|
||||
// recoverable, but the resume-selection prompt is blocking. Caller maps to
|
||||
// restoreOutcome=attention_required (do NOT auto-answer per Decision 2).
|
||||
@@ -54,7 +55,9 @@ export class ClaudeResumeAdapter {
|
||||
// unconditional acceptEdits floor when OFF; the full bypass when YOLO is ON) — every seat.
|
||||
// 0.5.2-07: --model matches the fresh-launch adapter (claude-code-adapter), emitted after posture.
|
||||
const modelArg = model ? ` --model ${shellQuote(model)}` : "";
|
||||
const cmd = `claude ${claudePostureFlag(process.env, resolvedPosture)}${modelArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
const permissionMode = claudePostureFlag(process.env, resolvedPosture);
|
||||
const appliedLaunch = observeClaudePermission(permissionMode);
|
||||
const cmd = `claude ${permissionMode}${modelArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
|
||||
const textResult = await this.tmux.sendText(tmuxSessionName, cmd);
|
||||
if (!textResult.ok) {
|
||||
@@ -70,7 +73,8 @@ export class ClaudeResumeAdapter {
|
||||
return { ok: false, code: "resume_failed", message: keyResult.message };
|
||||
}
|
||||
|
||||
return this.verifyResume(tmuxSessionName);
|
||||
const result = await this.verifyResume(tmuxSessionName);
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
private async verifyResume(tmuxSessionName: string): Promise<ResumeResult> {
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TmuxAdapter } from "./tmux.js";
|
||||
import type { ResumeResult } from "./claude-resume.js";
|
||||
import { assessNativeResumeProbe, buildCodexResumeCore } from "../domain/native-resume-probe.js";
|
||||
import { runSyncSite } from "../domain/sync-site-wrap.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { codexPostureArg } from "./yolo-mode.js";
|
||||
import { observeCodexSandbox } from "../domain/permission-drift.js";
|
||||
|
||||
const CODEX_TYPES = new Set(["codex_id", "codex_last"]);
|
||||
const SHELL_COMMANDS = new Set(["bash", "fish", "nu", "sh", "tmux", "zsh"]);
|
||||
@@ -66,6 +69,9 @@ export class CodexResumeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
const profileArg = codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : "";
|
||||
const postureArg = codexPostureArg(profileArg, process.env, resolvedPosture);
|
||||
const appliedLaunch = observeCodexSandbox(postureArg);
|
||||
const cmd = buildCodexResumeCore(
|
||||
resumeToken ?? "",
|
||||
codexConfigProfile,
|
||||
@@ -73,6 +79,7 @@ export class CodexResumeAdapter {
|
||||
undefined,
|
||||
resolvedPosture,
|
||||
model,
|
||||
postureArg,
|
||||
);
|
||||
|
||||
const textResult = await this.tmux.sendText(tmuxSessionName, cmd);
|
||||
@@ -89,7 +96,8 @@ export class CodexResumeAdapter {
|
||||
return { ok: false, code: "resume_failed", message: keyResult.message };
|
||||
}
|
||||
|
||||
return this.verifyResume(tmuxSessionName);
|
||||
const result = await this.verifyResume(tmuxSessionName);
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
// Mirrors ClaudeResumeAdapter.verifyResume: poll the pane, run the native
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import type { CodexProfileProbeResult } from "../domain/codex-profile-preflight.js";
|
||||
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
|
||||
import { observeCodexSandbox } from "../domain/permission-drift.js";
|
||||
import {
|
||||
defaultResolveHomeDirByPid,
|
||||
readCodexThreadIdFromCandidateHomes,
|
||||
@@ -308,6 +309,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const modelArg = model ? ` -m ${shellQuote(model)}` : "";
|
||||
const profile = binding.codexConfigProfile?.trim();
|
||||
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
|
||||
const postureArg = codexPostureArg(profileArg, process.env, binding.launchPosture);
|
||||
const appliedLaunch = observeCodexSandbox(postureArg);
|
||||
|
||||
// OPR.0.3.4.7 — profile-LOAD probe before launch/resume. A legacy
|
||||
// [profiles.<name>] table or invalid TOML must fail BEFORE the opaque
|
||||
@@ -343,7 +346,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// -s danger-full-access on every seat; otherwise the named profile, or OpenRig's explicit
|
||||
// -s workspace-write floor flag.
|
||||
// 0.5.2-07 A2-3: the FORK path threads the SPEC model too (fork-instantiate reverted it before).
|
||||
const cmd = `codex${codexPostureArg(profileArg, process.env, binding.launchPosture)}${modelArg} fork${queueStateDirArg} ${shellQuote(parentId)}`;
|
||||
const cmd = `codex${postureArg}${modelArg} fork${queueStateDirArg} ${shellQuote(parentId)}`;
|
||||
const textResult = await this.tmux.sendText(binding.tmuxSession, cmd);
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
@@ -360,7 +363,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
error: "codex fork: could not capture new post-fork thread id",
|
||||
};
|
||||
}
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id" };
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
|
||||
}
|
||||
|
||||
// OPR.0.4.8.2: one posture decision (codexPostureArg) for the fresh launch too — YOLO forces
|
||||
@@ -369,8 +372,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const cmd = opts.resumeToken
|
||||
// 0.5.2-07 A2-3: the pod-aware RESUME path threads the SPEC model too (reverted before — the
|
||||
// grounding map assumed codex parity with the claude adapter, but only fresh emitted -m).
|
||||
? buildCodexResumeCore(opts.resumeToken, profile, false, queueStateDirArg.trim() || undefined, binding.launchPosture, model)
|
||||
: `codex${codexPostureArg(profileArg, process.env, binding.launchPosture)} -C ${shellQuote(binding.cwd)}${gitDirArg}${queueStateDirArg}${modelArg}`;
|
||||
? buildCodexResumeCore(opts.resumeToken, profile, false, queueStateDirArg.trim() || undefined, binding.launchPosture, model, postureArg)
|
||||
: `codex${postureArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${queueStateDirArg}${modelArg}`;
|
||||
|
||||
const textResult = await this.tmux.sendText(binding.tmuxSession, cmd);
|
||||
if (!textResult.ok) {
|
||||
@@ -386,15 +389,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (opts.resumeToken) {
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession, { resumeToken: opts.resumeToken });
|
||||
if (!verification.ok) return verification;
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id" };
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch };
|
||||
}
|
||||
|
||||
const threadId = await this.captureFreshThreadId(binding);
|
||||
if (threadId) {
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id" };
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
return { ok: true, appliedLaunch };
|
||||
}
|
||||
|
||||
private buildQueueStateAddDirArg(sessionName: string): string {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { piTrust } from "./yolo-mode.js";
|
||||
import {
|
||||
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
|
||||
} from "./pi-runner-protocol.js";
|
||||
import { observePiResourceTrust } from "../domain/permission-drift.js";
|
||||
|
||||
export { type ResumeResult };
|
||||
|
||||
@@ -82,6 +83,8 @@ export class PiResumeAdapter {
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
|
||||
const trust = piTrust(this.options.trustPosture, process.env, resolvedPosture);
|
||||
const appliedLaunch = observePiResourceTrust(trust);
|
||||
const cmd = buildPiRunnerCommand({
|
||||
runnerEntryPath: this.paths.runnerEntryPath,
|
||||
sessionName: tmuxSessionName,
|
||||
@@ -92,7 +95,7 @@ export class PiResumeAdapter {
|
||||
model: model ?? undefined,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
|
||||
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
|
||||
trust: piTrust(this.options.trustPosture, process.env, resolvedPosture),
|
||||
trust,
|
||||
sessionFile,
|
||||
launchId,
|
||||
});
|
||||
@@ -109,7 +112,8 @@ export class PiResumeAdapter {
|
||||
return { ok: false, code: "resume_failed", message: keyResult.message };
|
||||
}
|
||||
|
||||
return this.verifyResume(tmuxSessionName, sessionFile, launchId);
|
||||
const result = await this.verifyResume(tmuxSessionName, sessionFile, launchId);
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
// Poll the runner's launch-scoped sidecar ONLY — never stale pane
|
||||
|
||||
@@ -22,6 +22,7 @@ import { resolveConcreteHint } from "../domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
|
||||
import { validateResumeToken } from "../domain/resume-token-validation.js";
|
||||
import { mergeManagedBlock } from "../domain/managed-blocks.js";
|
||||
import { observePiResourceTrust } from "../domain/permission-drift.js";
|
||||
import {
|
||||
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
|
||||
PI_RUNNER_READY_MARKER, PI_RUNNER_ERROR_MARKER, PI_RUNNER_EXIT_MARKER,
|
||||
@@ -233,6 +234,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
);
|
||||
|
||||
const trust = piTrust(this.trustPosture, process.env, binding.launchPosture);
|
||||
const appliedLaunch = observePiResourceTrust(trust);
|
||||
const cmd = buildPiRunnerCommand({
|
||||
runnerEntryPath: this.runnerEntryPath,
|
||||
sessionName,
|
||||
@@ -241,7 +244,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
model: binding.model,
|
||||
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
|
||||
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
|
||||
trust: piTrust(this.trustPosture, process.env, binding.launchPosture),
|
||||
trust,
|
||||
sessionFile: opts.resumeToken,
|
||||
forkRef,
|
||||
launchId,
|
||||
@@ -275,7 +278,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: `pi launch: the runner reported a malformed session file (${validation.error})` };
|
||||
}
|
||||
|
||||
return { ok: true, resumeToken: validation.token, resumeType: "pi_session_file" };
|
||||
return { ok: true, resumeToken: validation.token, resumeType: "pi_session_file", appliedLaunch };
|
||||
}
|
||||
|
||||
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
|
||||
|
||||
@@ -71,9 +71,10 @@ import { identityProvenanceSchema } from "./migrations/065_identity_provenance.j
|
||||
import { watchdogTargetGenerationSchema } from "./migrations/066_watchdog_target_generation.js";
|
||||
import { i3IdentityProvenanceSchema } from "./migrations/067_i3_identity_provenance.js";
|
||||
import { enforcerDecisionsSchema } from "./migrations/068_enforcer_decisions.js";
|
||||
import { appliedLaunchObservationsSchema } from "./migrations/069_applied_launch_observations.js";
|
||||
import type { Migration } from "./migrate.js";
|
||||
|
||||
/** Ordered 001→067 (064/066 from main, 065 + 067 the P21 identity_provenance era-stamps — all additive/independent). */
|
||||
/** Ordered 001→069 (068 enforcer decisions, 069 W3 applied-launch observations). */
|
||||
export const ALL_MIGRATIONS: Migration[] = [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
@@ -143,4 +144,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
watchdogTargetGenerationSchema,
|
||||
i3IdentityProvenanceSchema,
|
||||
enforcerDecisionsSchema,
|
||||
appliedLaunchObservationsSchema,
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
// W3 permission-drift visibility: an APPLIED launch observation is a separate,
|
||||
// generation-keyed fact. The occupant-tenure ledger stays append-only and is
|
||||
// never repurposed as mutable observation storage.
|
||||
export const appliedLaunchObservationsSchema: Migration = {
|
||||
name: "069_applied_launch_observations.sql",
|
||||
sql: `
|
||||
CREATE TABLE applied_launch_observations (
|
||||
generation_uuid TEXT PRIMARY KEY REFERENCES occupant_tenures(generation_uuid) ON DELETE CASCADE,
|
||||
runtime TEXT NOT NULL,
|
||||
axis TEXT NOT NULL,
|
||||
observation_state TEXT NOT NULL CHECK (observation_state IN ('observed', 'unknown')),
|
||||
value TEXT,
|
||||
reason TEXT,
|
||||
observed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { AppliedLaunchAxis, AppliedLaunchObservation, AppliedLaunchState } from "./permission-drift.js";
|
||||
|
||||
export interface StoredAppliedLaunchObservation extends AppliedLaunchObservation {
|
||||
generationUuid: string;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
interface ObservationRow {
|
||||
generation_uuid: string;
|
||||
runtime: string;
|
||||
axis: string;
|
||||
observation_state: string;
|
||||
value: string | null;
|
||||
reason: string | null;
|
||||
observed_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, generation-scoped persistence for the exact enforcing value
|
||||
* returned by a successful managed launch. Missing migrations and DB failures
|
||||
* degrade to UNKNOWN; they never turn a successful provider launch into a
|
||||
* failed launch.
|
||||
*/
|
||||
export class AppliedLaunchObservationStore {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
recordCurrent(nodeId: string, observation: AppliedLaunchObservation): boolean {
|
||||
try {
|
||||
const tenure = this.db.prepare(
|
||||
"SELECT generation_uuid FROM occupant_tenures WHERE node_id = ? ORDER BY generation_ordinal DESC LIMIT 1",
|
||||
).get(nodeId) as { generation_uuid: string } | undefined;
|
||||
if (!tenure) return false;
|
||||
this.db.prepare(`
|
||||
INSERT INTO applied_launch_observations (
|
||||
generation_uuid, runtime, axis, observation_state, value, reason, observed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(generation_uuid) DO UPDATE SET
|
||||
runtime = excluded.runtime,
|
||||
axis = excluded.axis,
|
||||
observation_state = excluded.observation_state,
|
||||
value = excluded.value,
|
||||
reason = excluded.reason,
|
||||
observed_at = excluded.observed_at
|
||||
`).run(
|
||||
tenure.generation_uuid,
|
||||
observation.runtime,
|
||||
observation.axis,
|
||||
observation.state,
|
||||
observation.value,
|
||||
observation.reason ?? null,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
readCurrent(nodeId: string): StoredAppliedLaunchObservation | null {
|
||||
try {
|
||||
const row = this.db.prepare(`
|
||||
SELECT o.*
|
||||
FROM (
|
||||
SELECT generation_uuid
|
||||
FROM occupant_tenures
|
||||
WHERE node_id = ?
|
||||
ORDER BY generation_ordinal DESC
|
||||
LIMIT 1
|
||||
) current_tenure
|
||||
JOIN applied_launch_observations o
|
||||
ON o.generation_uuid = current_tenure.generation_uuid
|
||||
`).get(nodeId) as ObservationRow | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
generationUuid: row.generation_uuid,
|
||||
runtime: row.runtime,
|
||||
axis: row.axis as AppliedLaunchAxis,
|
||||
state: row.observation_state as AppliedLaunchState,
|
||||
value: row.value,
|
||||
...(row.reason ? { reason: row.reason } : {}),
|
||||
observedAt: row.observed_at,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,14 @@ export function buildCodexResumeCore(
|
||||
// resumed seat boots on the spec model, not the runtime default; the non-launch consumers
|
||||
// (node inventory, resume-metadata) omit it and stay byte-identical.
|
||||
model?: string | null,
|
||||
/** Launch callers may pass the exact already-resolved segment they insert, avoiding a second policy decision. */
|
||||
precomputedPostureArg?: string,
|
||||
): string {
|
||||
// OPR.0.4.8.2: the RESUME path uses the SAME posture decision (codexPostureArg) as fresh/fork.
|
||||
// YOLO forces -s danger-full-access (overriding even a named profile); otherwise a named profile
|
||||
// governs itself and the no-profile case is OpenRig's explicit -s workspace-write floor flag.
|
||||
const profileArg = codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : "";
|
||||
const profileOrPosture = codexPostureArg(profileArg, process.env, resolvedPosture);
|
||||
const profileOrPosture = precomputedPostureArg ?? codexPostureArg(profileArg, process.env, resolvedPosture);
|
||||
// 0.5.2-07: -m is a top-level codex flag (matches the fresh-launch adapter), emitted before the
|
||||
// resume subcommand.
|
||||
const modelArg = model ? ` -m ${shellQuote(model)}` : "";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type Database from "better-sqlite3";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import {
|
||||
diagnoseRuntimePosture,
|
||||
parseClaudePermissionModes,
|
||||
type PermissionDriftDiagnostic,
|
||||
type PermissionDriftFs,
|
||||
} from "./permission-drift.js";
|
||||
|
||||
export interface PermissionDriftReader {
|
||||
diagnose(nodeId: string): PermissionDriftDiagnostic | null;
|
||||
}
|
||||
|
||||
function commandAvailable(command: string): boolean {
|
||||
const pathValue = process.env.PATH;
|
||||
if (!pathValue) return false;
|
||||
for (const entry of pathValue.split(nodePath.delimiter)) {
|
||||
if (!entry) continue;
|
||||
try {
|
||||
fs.accessSync(nodePath.join(entry, command), fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
// Try the next PATH entry.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function productionFs(): PermissionDriftFs {
|
||||
return {
|
||||
readFile: (path) => fs.readFileSync(path, "utf8"),
|
||||
cwdReadable: (path) => {
|
||||
fs.accessSync(path, fs.constants.R_OK);
|
||||
return true;
|
||||
},
|
||||
commandAvailable,
|
||||
claudePermissionModes: () => {
|
||||
const result = spawnSync("claude", ["--help"], {
|
||||
encoding: "utf8",
|
||||
timeout: 2_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0 || result.error) return null;
|
||||
return parseClaudePermissionModes(result.stdout);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Strict, read-only, generation-aware observer for an explicitly requested seat. */
|
||||
export class PermissionDriftObserver implements PermissionDriftReader {
|
||||
private readonly observations: AppliedLaunchObservationStore;
|
||||
private readonly fs: PermissionDriftFs;
|
||||
private readonly now?: () => Date;
|
||||
|
||||
constructor(
|
||||
private readonly input: { db: Database.Database; fs?: PermissionDriftFs; now?: () => Date },
|
||||
) {
|
||||
this.observations = new AppliedLaunchObservationStore(input.db);
|
||||
this.fs = input.fs ?? productionFs();
|
||||
this.now = input.now;
|
||||
}
|
||||
|
||||
diagnose(nodeId: string): PermissionDriftDiagnostic | null {
|
||||
const node = this.input.db.prepare("SELECT runtime, cwd FROM nodes WHERE id = ?").get(nodeId) as {
|
||||
runtime: string | null;
|
||||
cwd: string | null;
|
||||
} | undefined;
|
||||
if (!node) return null;
|
||||
return diagnoseRuntimePosture({
|
||||
runtime: node.runtime ?? "unknown",
|
||||
cwd: node.cwd,
|
||||
applied: this.observations.readCurrent(nodeId),
|
||||
fs: this.fs,
|
||||
now: this.now,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import nodePath from "node:path";
|
||||
|
||||
export type AppliedLaunchAxis = "permission" | "sandbox" | "resource_trust" | "not_applicable";
|
||||
export type AppliedLaunchState = "observed" | "unknown";
|
||||
|
||||
export interface AppliedLaunchObservation {
|
||||
runtime: string;
|
||||
axis: AppliedLaunchAxis;
|
||||
state: AppliedLaunchState;
|
||||
value: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type TransportState = "healthy" | "connect" | "timeout" | "response";
|
||||
export type CwdReadState = "visible" | "denied" | "unknown";
|
||||
export type CommandPathState = "available" | "missing" | "unknown";
|
||||
export type EnforcementState = "aligned" | "drift" | "unknown";
|
||||
|
||||
export interface EffectiveClaudePermission {
|
||||
defaultMode: string;
|
||||
allow: string[];
|
||||
ask: string[];
|
||||
deny: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeEnforcementDiagnostic {
|
||||
axis: AppliedLaunchAxis;
|
||||
state: EnforcementState;
|
||||
expected: string | null;
|
||||
effective: string | EffectiveClaudePermission | null;
|
||||
sourcePath: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface PermissionDriftDiagnostic {
|
||||
transport: { state: TransportState };
|
||||
cwdRead: { state: CwdReadState };
|
||||
commandPath: { state: CommandPathState };
|
||||
enforcement: RuntimeEnforcementDiagnostic;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface PermissionDriftFs {
|
||||
readFile(path: string): string;
|
||||
cwdReadable(path: string): boolean | null;
|
||||
commandAvailable(command: string): boolean | null;
|
||||
/** Live harness-derived Claude permission-mode vocabulary. null = unresolved. */
|
||||
claudePermissionModes(): string[] | null;
|
||||
}
|
||||
|
||||
export function parseClaudePermissionModes(help: string): string[] | null {
|
||||
const start = help.indexOf("--permission-mode <mode>");
|
||||
if (start < 0) return null;
|
||||
const block = help.slice(start, start + 600);
|
||||
const choices = block.match(/choices:\s*([^)]+)\)/)?.[1];
|
||||
if (!choices) return null;
|
||||
const values = [...choices.matchAll(/"([^"]+)"/g)].map((match) => match[1]!);
|
||||
return values.length > 0 ? values : null;
|
||||
}
|
||||
|
||||
export function observeClaudePermission(flag: string): AppliedLaunchObservation {
|
||||
if (flag.trim() === "--permission-mode acceptEdits") {
|
||||
return { runtime: "claude-code", axis: "permission", state: "observed", value: "acceptEdits" };
|
||||
}
|
||||
if (flag.trim() === "--dangerously-skip-permissions") {
|
||||
return { runtime: "claude-code", axis: "permission", state: "observed", value: "bypassPermissions" };
|
||||
}
|
||||
return { runtime: "claude-code", axis: "permission", state: "unknown", value: null, reason: "unrecognized_launch_argument" };
|
||||
}
|
||||
|
||||
export function observeCodexSandbox(arg: string): AppliedLaunchObservation {
|
||||
const normalized = arg.trim().replace(/\s+/g, " ");
|
||||
if (normalized === "-s workspace-write") {
|
||||
return { runtime: "codex", axis: "sandbox", state: "observed", value: "workspace-write" };
|
||||
}
|
||||
if (normalized === "-s danger-full-access") {
|
||||
return { runtime: "codex", axis: "sandbox", state: "observed", value: "danger-full-access" };
|
||||
}
|
||||
if (normalized.startsWith("-p ")) {
|
||||
return { runtime: "codex", axis: "sandbox", state: "unknown", value: null, reason: "named_profile_unresolved" };
|
||||
}
|
||||
return { runtime: "codex", axis: "sandbox", state: "unknown", value: null, reason: "unrecognized_launch_argument" };
|
||||
}
|
||||
|
||||
export function observePiResourceTrust(trust: "approve" | "no-approve"): AppliedLaunchObservation {
|
||||
return { runtime: "pi", axis: "resource_trust", state: "observed", value: trust };
|
||||
}
|
||||
|
||||
function runtimeCommand(runtime: string): string | null {
|
||||
if (runtime === "claude-code") return "claude";
|
||||
if (runtime === "codex") return "codex";
|
||||
if (runtime === "pi") return "pi";
|
||||
return null;
|
||||
}
|
||||
|
||||
function cwdReadState(fs: PermissionDriftFs, cwd: string | null): CwdReadState {
|
||||
if (!cwd) return "unknown";
|
||||
try {
|
||||
const value = fs.cwdReadable(cwd);
|
||||
return value === true ? "visible" : value === false ? "denied" : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function commandPathState(fs: PermissionDriftFs, runtime: string): CommandPathState {
|
||||
const command = runtimeCommand(runtime);
|
||||
if (!command) return "unknown";
|
||||
try {
|
||||
const value = fs.commandAvailable(command);
|
||||
return value === true ? "available" : value === false ? "missing" : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function unknownEnforcement(
|
||||
axis: AppliedLaunchAxis,
|
||||
expected: string | null,
|
||||
sourcePath: string | null,
|
||||
reason: string,
|
||||
): RuntimeEnforcementDiagnostic {
|
||||
return { axis, state: "unknown", expected, effective: null, sourcePath, reason };
|
||||
}
|
||||
|
||||
function parseStringArray(value: unknown): string[] | null {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) return null;
|
||||
return [...value];
|
||||
}
|
||||
|
||||
function inspectClaude(
|
||||
cwd: string | null,
|
||||
applied: AppliedLaunchObservation | null,
|
||||
fs: PermissionDriftFs,
|
||||
): RuntimeEnforcementDiagnostic {
|
||||
const expected = applied?.state === "observed" ? applied.value : null;
|
||||
if (!cwd) {
|
||||
return unknownEnforcement("permission", expected, null, "cwd_unknown");
|
||||
}
|
||||
const sourcePath = nodePath.join(cwd, ".claude", "settings.local.json");
|
||||
if (!applied || applied.runtime !== "claude-code" || applied.axis !== "permission" || applied.state !== "observed" || !expected) {
|
||||
return unknownEnforcement("permission", expected, sourcePath, applied?.reason ?? "applied_launch_unknown");
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFile(sourcePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return unknownEnforcement("permission", expected, sourcePath, code === "ENOENT" ? "settings_missing" : "settings_unreadable");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "settings_unparseable");
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "settings_invalid_shape");
|
||||
}
|
||||
|
||||
const permissions = (parsed as Record<string, unknown>).permissions;
|
||||
if (typeof permissions !== "object" || permissions === null || Array.isArray(permissions)) {
|
||||
return unknownEnforcement("permission", expected, sourcePath, permissions === undefined ? "permissions_missing" : "permissions_invalid_shape");
|
||||
}
|
||||
const record = permissions as Record<string, unknown>;
|
||||
const defaultMode = record.defaultMode;
|
||||
if (typeof defaultMode !== "string") {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "default_mode_missing");
|
||||
}
|
||||
let supportedModes: Set<string>;
|
||||
try {
|
||||
const liveModes = fs.claudePermissionModes();
|
||||
if (!liveModes || liveModes.length === 0) {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "harness_semantics_unknown");
|
||||
}
|
||||
supportedModes = new Set(liveModes);
|
||||
} catch {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "harness_semantics_unknown");
|
||||
}
|
||||
if (!supportedModes.has(defaultMode)) {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "unsupported_default_mode");
|
||||
}
|
||||
const allow = parseStringArray(record.allow);
|
||||
const ask = parseStringArray(record.ask);
|
||||
const deny = parseStringArray(record.deny);
|
||||
if (!allow || !ask || !deny) {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "permission_rules_invalid_shape");
|
||||
}
|
||||
const denySet = new Set(deny);
|
||||
if (allow.some((entry) => denySet.has(entry))) {
|
||||
return unknownEnforcement("permission", expected, sourcePath, "conflicting_rules");
|
||||
}
|
||||
|
||||
const effective: EffectiveClaudePermission = { defaultMode, allow, ask, deny };
|
||||
return {
|
||||
axis: "permission",
|
||||
state: defaultMode === expected ? "aligned" : "drift",
|
||||
expected,
|
||||
effective,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectLaunchBoundRuntime(
|
||||
runtime: string,
|
||||
applied: AppliedLaunchObservation | null,
|
||||
): RuntimeEnforcementDiagnostic {
|
||||
const axis: AppliedLaunchAxis = runtime === "codex" ? "sandbox" : runtime === "pi" ? "resource_trust" : "not_applicable";
|
||||
if (!applied || applied.runtime !== runtime || applied.axis !== axis || applied.state !== "observed" || !applied.value) {
|
||||
return unknownEnforcement(axis, null, null, applied?.reason ?? "applied_launch_unknown");
|
||||
}
|
||||
return {
|
||||
axis,
|
||||
state: "aligned",
|
||||
expected: applied.value,
|
||||
effective: applied.value,
|
||||
sourcePath: null,
|
||||
reason: "generation_matched_launch_effect",
|
||||
};
|
||||
}
|
||||
|
||||
export function diagnoseRuntimePosture(input: {
|
||||
runtime: string;
|
||||
cwd: string | null;
|
||||
applied: AppliedLaunchObservation | null;
|
||||
fs: PermissionDriftFs;
|
||||
now?: () => Date;
|
||||
}): PermissionDriftDiagnostic {
|
||||
const enforcement = input.runtime === "claude-code"
|
||||
? inspectClaude(input.cwd, input.applied, input.fs)
|
||||
: inspectLaunchBoundRuntime(input.runtime, input.applied);
|
||||
return {
|
||||
transport: { state: "healthy" },
|
||||
cwdRead: { state: cwdReadState(input.fs, input.cwd) },
|
||||
commandPath: { state: commandPathState(input.fs, input.runtime) },
|
||||
enforcement,
|
||||
observedAt: (input.now?.() ?? new Date()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPermissionDriftSummary(diagnostic: PermissionDriftDiagnostic): string {
|
||||
const axisLabel = diagnostic.enforcement.axis === "resource_trust"
|
||||
? "resource trust"
|
||||
: diagnostic.enforcement.axis;
|
||||
const effective = typeof diagnostic.enforcement.effective === "object" && diagnostic.enforcement.effective !== null
|
||||
? diagnostic.enforcement.effective.defaultMode
|
||||
: diagnostic.enforcement.effective;
|
||||
const pieces = [
|
||||
`transport: ${diagnostic.transport.state}`,
|
||||
`cwd/read: ${diagnostic.cwdRead.state}`,
|
||||
`command/PATH: ${diagnostic.commandPath.state}`,
|
||||
`${axisLabel}: ${diagnostic.enforcement.state.toUpperCase()}`,
|
||||
];
|
||||
if (diagnostic.enforcement.expected) pieces.push(`expected=${diagnostic.enforcement.expected}`);
|
||||
if (effective) pieces.push(`effective=${effective}`);
|
||||
if (diagnostic.enforcement.sourcePath) pieces.push(`source=${diagnostic.enforcement.sourcePath}`);
|
||||
if (diagnostic.enforcement.reason) pieces.push(`reason=${diagnostic.enforcement.reason}`);
|
||||
return pieces.join(" · ");
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
Checkpoint,
|
||||
RigServicesRecord,
|
||||
} from "./types.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
|
||||
// L3: result shape for runtime-truth reconciliation. A reconciliation that
|
||||
// does NOT meet all four evidence preconditions is a no-op with a missing
|
||||
@@ -119,6 +120,7 @@ export class RestoreOrchestrator {
|
||||
private piResume: PiResumeAdapter | null;
|
||||
private transcriptStore: TranscriptStore | null;
|
||||
private serviceOrchestrator: import("./service-orchestrator.js").ServiceOrchestrator | null;
|
||||
private appliedLaunchStore: AppliedLaunchObservationStore;
|
||||
|
||||
constructor(deps: RestoreOrchestratorDeps) {
|
||||
if (deps.db !== deps.rigRepo.db) {
|
||||
@@ -156,6 +158,7 @@ export class RestoreOrchestrator {
|
||||
this.piResume = deps.piResume ?? null;
|
||||
this.transcriptStore = deps.transcriptStore ?? null;
|
||||
this.serviceOrchestrator = deps.serviceOrchestrator ?? null;
|
||||
this.appliedLaunchStore = new AppliedLaunchObservationStore(deps.db);
|
||||
}
|
||||
|
||||
async restore(snapshotId: string, opts?: {
|
||||
@@ -984,7 +987,7 @@ export class RestoreOrchestrator {
|
||||
await this.rollbackToZeroSession(node.id, sessionName, launchResult?.session.id, priorState);
|
||||
return { nodeId: node.id, logicalId: node.logicalId, status: "awaiting-decision", error: `Original session unresumable: resume requested but no token available. No session is running. Re-run with --fresh ${node.logicalId} for a deliberate fresh-primed seat, or restore the original session manually.` };
|
||||
} else {
|
||||
const resumeOutcome = await this.attemptResume(sessionName, resumeType, resumeToken, node.cwd ?? "/", node.codexConfigProfile, node.model, this.resolveRestorePosture(node.id, rigId));
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", node.codexConfigProfile, node.model, this.resolveRestorePosture(node.id, rigId));
|
||||
if (resumeOutcome.kind === "resumed") {
|
||||
baseStatus = "resumed";
|
||||
} else if (resumeOutcome.kind === "attention_required") {
|
||||
@@ -1305,6 +1308,7 @@ export class RestoreOrchestrator {
|
||||
}
|
||||
|
||||
private async attemptResume(
|
||||
nodeId: string,
|
||||
sessionName: string,
|
||||
resumeType: string,
|
||||
resumeToken: string | null,
|
||||
@@ -1322,7 +1326,10 @@ export class RestoreOrchestrator {
|
||||
> {
|
||||
if (this.claudeResume.canResume(resumeType, resumeToken)) {
|
||||
const result = await this.claudeResume.resume(sessionName, resumeType, resumeToken, cwd, resolvedPosture, model);
|
||||
if (result.ok) return { kind: "resumed" };
|
||||
if (result.ok) {
|
||||
if (result.appliedLaunch) this.appliedLaunchStore.recordCurrent(nodeId, result.appliedLaunch);
|
||||
return { kind: "resumed" };
|
||||
}
|
||||
if (result.code === "retry_fresh") return { kind: "retry_fresh" };
|
||||
// L3: surface attention_required from the Claude probe (resume-selection prompt).
|
||||
if (result.code === "attention_required") {
|
||||
@@ -1337,7 +1344,10 @@ export class RestoreOrchestrator {
|
||||
|
||||
if (this.codexResume.canResume(resumeType, resumeToken)) {
|
||||
const result = await this.codexResume.resume(sessionName, resumeType, resumeToken, cwd, codexConfigProfile, resolvedPosture, model);
|
||||
if (result.ok) return { kind: "resumed" };
|
||||
if (result.ok) {
|
||||
if (result.appliedLaunch) this.appliedLaunchStore.recordCurrent(nodeId, result.appliedLaunch);
|
||||
return { kind: "resumed" };
|
||||
}
|
||||
if (result.code === "retry_fresh") return { kind: "retry_fresh" };
|
||||
// Codex auth-refusal: stored OAuth token can no longer be refreshed.
|
||||
// Recoverable — operator runs `codex login` and the seat continues.
|
||||
@@ -1358,7 +1368,10 @@ export class RestoreOrchestrator {
|
||||
// awaiting-decision stop-and-ask — never a silent fresh start (BR-6).
|
||||
if (this.piResume?.canResume(resumeType, resumeToken)) {
|
||||
const result = await this.piResume.resume(sessionName, resumeType, resumeToken, cwd, model, resolvedPosture);
|
||||
if (result.ok) return { kind: "resumed" };
|
||||
if (result.ok) {
|
||||
if (result.appliedLaunch) this.appliedLaunchStore.recordCurrent(nodeId, result.appliedLaunch);
|
||||
return { kind: "resumed" };
|
||||
}
|
||||
if (result.code === "retry_fresh") return { kind: "retry_fresh" };
|
||||
if (result.code === "attention_required") {
|
||||
return {
|
||||
|
||||
@@ -76,7 +76,7 @@ export function isAttentionRequiredReadinessCode(code: string | undefined): bool
|
||||
export type HarnessLaunchRecovery = "retry_fresh" | "attention_required";
|
||||
|
||||
export type HarnessLaunchResult =
|
||||
| { ok: true; resumeToken?: string; resumeType?: string }
|
||||
| { ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: import("./permission-drift.js").AppliedLaunchObservation }
|
||||
// `evidence` carries the last-N pane lines for `attention_required` outcomes
|
||||
// so the failure can flow honest evidence through to RestoreNodeResult's
|
||||
// attentionEvidence field. Omitted for non-attention recoveries.
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { RuntimeAdapter } from "./runtime-adapter.js";
|
||||
import type { OccupantInvalidator } from "./occupant-invalidator.js";
|
||||
import type { JsonlExchange } from "./session-jsonl.js";
|
||||
import type { PersistedEvent } from "./types.js";
|
||||
import type { AppliedLaunchObservation } from "./permission-drift.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
|
||||
/** Stopgap: a bounded labeled-from-record recap of the predecessor's last exchanges + the record path,
|
||||
* resolved from the predecessor's provider transcript (claude transcript_path / codex rollout_path). */
|
||||
@@ -154,6 +156,7 @@ export class SeatHandoverService {
|
||||
private captureDeps: ResumeTokenCaptureDeps;
|
||||
private occupantInvalidator: OccupantInvalidator | null;
|
||||
private predecessorRecapResolver: PredecessorRecapResolver | null;
|
||||
private appliedLaunchObservations: AppliedLaunchObservationStore;
|
||||
private now: () => Date;
|
||||
|
||||
constructor(deps: SeatHandoverServiceDeps) {
|
||||
@@ -169,6 +172,7 @@ export class SeatHandoverService {
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.occupantInvalidator = deps.occupantInvalidator ?? null;
|
||||
this.predecessorRecapResolver = deps.predecessorRecapResolver ?? null;
|
||||
this.appliedLaunchObservations = new AppliedLaunchObservationStore(deps.db);
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.statusService = new SeatStatusService({ rigRepo: deps.rigRepo });
|
||||
this.planner = new SeatHandoverPlanner({ rigRepo: deps.rigRepo });
|
||||
@@ -295,6 +299,7 @@ export class SeatHandoverService {
|
||||
contextDelivered: false,
|
||||
launchToken: null,
|
||||
occupantGeneration: null,
|
||||
appliedLaunch: null,
|
||||
cleanup: null,
|
||||
});
|
||||
}
|
||||
@@ -392,6 +397,7 @@ export class SeatHandoverService {
|
||||
// successor launcher is persisted atomically at commit (provenance scrape).
|
||||
launchToken: launch.resumeToken ? { token: launch.resumeToken, resumeType: launch.resumeType } : null,
|
||||
occupantGeneration,
|
||||
appliedLaunch: launch.appliedLaunch ?? null,
|
||||
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId),
|
||||
});
|
||||
}
|
||||
@@ -417,6 +423,8 @@ export class SeatHandoverService {
|
||||
launchToken: { token: string; resumeType?: string } | null;
|
||||
/** Source-bound generation reserved before a fresh successor started; null for discovered seats. */
|
||||
occupantGeneration: string | null;
|
||||
/** Exact enforcing value returned by the launch adapter; absent for adopted/discovered successors. */
|
||||
appliedLaunch: AppliedLaunchObservation | null;
|
||||
cleanup: (() => Promise<void>) | null;
|
||||
}): Promise<SeatHandoverResult> {
|
||||
const fail = async (result: SeatHandoverResult): Promise<SeatHandoverResult> => {
|
||||
@@ -499,6 +507,7 @@ export class SeatHandoverService {
|
||||
contextDelivered: input.contextDelivered,
|
||||
launchToken: input.launchToken,
|
||||
occupantGeneration: input.occupantGeneration,
|
||||
appliedLaunch: input.appliedLaunch,
|
||||
});
|
||||
if (!committed.ok) return fail(committed);
|
||||
|
||||
@@ -670,6 +679,7 @@ export class SeatHandoverService {
|
||||
contextDelivered: boolean;
|
||||
launchToken: { token: string; resumeType?: string } | null;
|
||||
occupantGeneration: string | null;
|
||||
appliedLaunch: AppliedLaunchObservation | null;
|
||||
}): SeatHandoverResult {
|
||||
const handoverAt = this.now().toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
@@ -702,6 +712,13 @@ export class SeatHandoverService {
|
||||
"handover",
|
||||
input.occupantGeneration,
|
||||
);
|
||||
// W3: registerClaimedSession mints the successor generation. Only now may
|
||||
// the exact adapter-returned launch effect be attached to that tenure.
|
||||
// The store is best-effort, so observation persistence can never make an
|
||||
// otherwise successful handover fail.
|
||||
if (input.appliedLaunch) {
|
||||
this.appliedLaunchObservations.recordCurrent(input.node.id, input.appliedLaunch);
|
||||
}
|
||||
// B2 (launched/fresh): persist the launch-scraped resume token atomically
|
||||
// with the claim, provenance "scrape" (mirrors StartupOrchestrator's
|
||||
// launch-token capture). Validity-guarded; a malformed token is dropped,
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import { isAttentionRequiredReadinessCode, resolveConcreteHint } from "./runtime-adapter.js";
|
||||
import type { ProjectionPlan } from "./projection-planner.js";
|
||||
import { issueStartupChallenge } from "./startup-proof.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import type { AppliedLaunchObservation } from "./permission-drift.js";
|
||||
|
||||
// -- Types --
|
||||
|
||||
@@ -96,6 +98,7 @@ export class StartupOrchestrator {
|
||||
private eventBus: EventBus;
|
||||
private tmuxAdapter: TmuxAdapter;
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
private appliedLaunchStore: AppliedLaunchObservationStore;
|
||||
|
||||
constructor(deps: StartupOrchestratorDeps) {
|
||||
if (deps.db !== deps.sessionRegistry.db) throw new Error("StartupOrchestrator: sessionRegistry must share the same db handle");
|
||||
@@ -106,6 +109,7 @@ export class StartupOrchestrator {
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.readFile = deps.readFile ?? (() => "");
|
||||
this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
this.appliedLaunchStore = new AppliedLaunchObservationStore(deps.db);
|
||||
}
|
||||
|
||||
private readFile: (path: string) => string;
|
||||
@@ -119,6 +123,7 @@ export class StartupOrchestrator {
|
||||
: input.rebuildArtifacts && input.rebuildArtifacts.length > 0
|
||||
? "rebuilt"
|
||||
: "fresh";
|
||||
let appliedLaunch: AppliedLaunchObservation | undefined;
|
||||
|
||||
// 1. Mark pending
|
||||
this.sessionRegistry.updateStartupStatus(input.sessionId, "pending");
|
||||
@@ -193,6 +198,7 @@ export class StartupOrchestrator {
|
||||
...(input.forkSource && !launchResumeToken ? { forkSource: input.forkSource } : {}),
|
||||
});
|
||||
if (launchResult.ok) {
|
||||
appliedLaunch = launchResult.appliedLaunch;
|
||||
const normalizedResumeToken = launchResult.resumeToken?.trim();
|
||||
if (normalizedResumeToken) {
|
||||
try {
|
||||
@@ -251,6 +257,11 @@ export class StartupOrchestrator {
|
||||
return this.fail(input, "failed", errors);
|
||||
}
|
||||
|
||||
// The adapter returned the exact enforcing value it inserted, and readiness
|
||||
// proved this managed launch became live. Persistence is deliberately
|
||||
// best-effort: observation failure yields UNKNOWN, never a failed launch.
|
||||
if (appliedLaunch) this.appliedLaunchStore.recordCurrent(input.nodeId, appliedLaunch);
|
||||
|
||||
// OPR.0.4.3.06 — a fresh or fresh-fallback MANAGED launch is challenged so
|
||||
// orientation can be proved (never assumed). Resumed restores (excluded by
|
||||
// the `fresh` gate) and skip-harness/legacy no-contract paths are NOT
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DiscoveryRepository } from "./discovery-repository.js";
|
||||
import type { RuntimeHint } from "./discovery-types.js";
|
||||
import type { RuntimeAdapter, NodeBinding, ReadinessResult } from "./runtime-adapter.js";
|
||||
import { isAttentionRequiredReadinessCode } from "./runtime-adapter.js";
|
||||
import type { AppliedLaunchObservation } from "./permission-drift.js";
|
||||
import type { TmuxOptionDefaultsApplier } from "./tmux-option-defaults.js";
|
||||
|
||||
/**
|
||||
@@ -57,6 +58,7 @@ export type SuccessorLaunchResult =
|
||||
tmuxPane: string;
|
||||
resumeToken?: string;
|
||||
resumeType?: string;
|
||||
appliedLaunch?: AppliedLaunchObservation;
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — non-fatal tmux option-default warnings from the
|
||||
* fresh successor's launch (mouse/status/clipboard). Present only when
|
||||
@@ -222,6 +224,7 @@ export class SuccessorSessionLauncher {
|
||||
tmuxPane: pane.id,
|
||||
resumeToken: started.resumeToken,
|
||||
resumeType: started.resumeType,
|
||||
appliedLaunch: started.appliedLaunch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,7 +240,7 @@ export class SuccessorSessionLauncher {
|
||||
tmuxSession: string,
|
||||
tmuxPane: string,
|
||||
cwd: string | undefined,
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string } | { ok: false; code: string; message: string }> {
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation } | { ok: false; code: string; message: string }> {
|
||||
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
|
||||
if (!adapter) {
|
||||
return {
|
||||
@@ -316,7 +319,7 @@ export class SuccessorSessionLauncher {
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, resumeToken, resumeType };
|
||||
return { ok: true, resumeToken, resumeType, appliedLaunch: launch.appliedLaunch };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -707,6 +707,8 @@ export interface NodeDetailCompactSpec {
|
||||
}
|
||||
|
||||
export interface NodeDetailEntry extends NodeInventoryEntry {
|
||||
/** W3 opt-in single-seat diagnostic; never populated on inventory lists. */
|
||||
permissionDrift?: import("./permission-drift.js").PermissionDriftDiagnostic | null;
|
||||
binding: Binding | null;
|
||||
startupFiles: Array<{ path: string; deliveryHint: string; required: boolean }>;
|
||||
startupActions: Array<{ type: string; value: string }>;
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface WhoamiResult {
|
||||
* rig's RigSpec declares one. null when the rig has no workspace
|
||||
* declaration; agents fall back to cwd-only orientation. */
|
||||
workspace?: WhoamiWorkspaceBlock | null;
|
||||
/** W3 explicit current-seat diagnostic; omitted from ordinary whoami reads. */
|
||||
permissionDrift?: import("./permission-drift.js").PermissionDriftDiagnostic | null;
|
||||
}
|
||||
|
||||
export type RuntimeContext =
|
||||
|
||||
@@ -29,6 +29,7 @@ import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { validateResumeToken } from "../domain/resume-token-validation.js";
|
||||
import type { PermissionDriftReader } from "../domain/permission-drift-observer.js";
|
||||
|
||||
function terminalAuthGuard(): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
@@ -156,6 +157,15 @@ nodesRoutes.get("/:logicalId", async (c) => {
|
||||
pendingWorkCount: detailWithTerminalAndWork?.pendingWorkCount,
|
||||
});
|
||||
|
||||
// W3: detail is a single explicit seat, so this read-only filesystem
|
||||
// observation is allowed here. It is deliberately absent from the list path.
|
||||
const observer = c.get("permissionDriftObserver" as never) as PermissionDriftReader | undefined;
|
||||
if (observer) {
|
||||
const row = deps.rigRepo.db.prepare("SELECT id FROM nodes WHERE rig_id = ? AND logical_id = ?")
|
||||
.get(rigId, logicalId) as { id: string } | undefined;
|
||||
if (row) detail.permissionDrift = observer.diagnose(row.id);
|
||||
}
|
||||
|
||||
// PL-019 item 5: surface in-progress qitems on node-detail when the
|
||||
// node has a session name (matches /graph payload's enrichment shape).
|
||||
if (detail.canonicalSessionName) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { WhoamiService, WhoamiAmbiguousError } from "../domain/whoami-service.js";
|
||||
import type { PermissionDriftReader } from "../domain/permission-drift-observer.js";
|
||||
|
||||
export function whoamiRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
@@ -13,6 +14,7 @@ export function whoamiRoutes(): Hono {
|
||||
// compute). A direct /api/whoami with NO compact param stays FULL (API
|
||||
// back-compat for external consumers).
|
||||
const compact = c.req.query("compact") === "1";
|
||||
const permissionDiagnostics = c.req.query("diagnostics") === "permission";
|
||||
|
||||
if (!nodeId && !sessionName) {
|
||||
return c.json({
|
||||
@@ -35,6 +37,11 @@ export function whoamiRoutes(): Hono {
|
||||
}, 404);
|
||||
}
|
||||
|
||||
if (permissionDiagnostics) {
|
||||
const observer = c.get("permissionDriftObserver" as never) as PermissionDriftReader | undefined;
|
||||
if (observer) result.permissionDrift = observer.diagnose(result.identity.nodeId);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof WhoamiAmbiguousError) {
|
||||
|
||||
@@ -84,6 +84,7 @@ import { whoamiRoutes } from "./routes/whoami.js";
|
||||
import { providerRoutes } from "./routes/provider.js";
|
||||
import type { ProviderService } from "./domain/provider/provider-service.js";
|
||||
import type { WhoamiService } from "./domain/whoami-service.js";
|
||||
import { PermissionDriftObserver, type PermissionDriftReader } from "./domain/permission-drift-observer.js";
|
||||
import { chatRoutes } from "./routes/chat.js";
|
||||
import { streamRoutes } from "./routes/stream.js";
|
||||
import { queueRoutes } from "./routes/queue.js";
|
||||
@@ -283,6 +284,8 @@ export interface AppDeps {
|
||||
* per scan so newly-installed specs get picked up). */
|
||||
agentImageSpecRoots?: () => readonly string[];
|
||||
whoamiService?: WhoamiService;
|
||||
/** W3 single-seat, read-only runtime-policy observer. */
|
||||
permissionDriftObserver?: PermissionDriftReader;
|
||||
contextUsageStore?: import("./domain/context-usage-store.js").ContextUsageStore;
|
||||
contextMonitor?: { pollOnce(): Promise<void> };
|
||||
/**
|
||||
@@ -437,6 +440,8 @@ export function createApp(deps: AppDeps): Hono {
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
const permissionDriftObserver = deps.permissionDriftObserver
|
||||
?? new PermissionDriftObserver({ db: deps.rigRepo.db });
|
||||
|
||||
// Inject dependencies into context for all routes
|
||||
app.use("*", async (c, next) => {
|
||||
@@ -528,6 +533,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("agentImageLibrary" as never, deps.agentImageLibrary);
|
||||
c.set("snapshotCapturer" as never, deps.snapshotCapturer);
|
||||
c.set("whoamiService" as never, deps.whoamiService);
|
||||
c.set("permissionDriftObserver" as never, permissionDriftObserver);
|
||||
c.set("contextUsageStore" as never, deps.contextUsageStore);
|
||||
c.set("contextMonitor" as never, deps.contextMonitor);
|
||||
c.set("compactionEnforcer" as never, deps.compactionEnforcer);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import type { Database as DatabaseType } from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { observeClaudePermission } from "../src/domain/permission-drift.js";
|
||||
|
||||
describe("AppliedLaunchObservationStore", () => {
|
||||
let db: DatabaseType;
|
||||
let registry: SessionRegistry;
|
||||
let store: AppliedLaunchObservationStore;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-1", "r1");
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, role, runtime, cwd) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run("node-1", "rig-1", "dev.impl", "worker", "claude-code", "/tmp/project");
|
||||
registry = new SessionRegistry(db);
|
||||
store = new AppliedLaunchObservationStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("records against the exact current generation without mutating the append-only tenure row", () => {
|
||||
registry.registerSession("node-1", "dev-impl@r1");
|
||||
const tenure = registry.currentOccupantTenure("node-1")!;
|
||||
expect(store.recordCurrent("node-1", observeClaudePermission("--permission-mode acceptEdits"))).toBe(true);
|
||||
expect(store.readCurrent("node-1")).toMatchObject({
|
||||
generationUuid: tenure.generationUuid,
|
||||
runtime: "claude-code",
|
||||
axis: "permission",
|
||||
state: "observed",
|
||||
value: "acceptEdits",
|
||||
});
|
||||
expect(registry.currentOccupantTenure("node-1")).toEqual(tenure);
|
||||
});
|
||||
|
||||
it("never inherits a predecessor observation after a new occupant generation is minted", () => {
|
||||
registry.registerSession("node-1", "dev-impl@r1");
|
||||
const first = registry.currentOccupantTenure("node-1")!;
|
||||
expect(store.recordCurrent("node-1", observeClaudePermission("--permission-mode acceptEdits"))).toBe(true);
|
||||
|
||||
registry.mintOccupantTenure("node-1", "handover");
|
||||
expect(registry.currentOccupantTenure("node-1")!.generationUuid).not.toBe(first.generationUuid);
|
||||
expect(store.readCurrent("node-1")).toBeNull();
|
||||
expect(db.prepare("SELECT COUNT(*) AS n FROM applied_launch_observations").get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it("keeps adopted/discovered/unlaunched occupants unknown until a successful launch records an effect", () => {
|
||||
registry.registerClaimedSession("node-1", "dev-impl@r1", "adopt");
|
||||
expect(store.readCurrent("node-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("degrades missing migration/read/write failures to unknown without throwing", () => {
|
||||
const bare = new Database(":memory:");
|
||||
try {
|
||||
const missing = new AppliedLaunchObservationStore(bare);
|
||||
expect(missing.recordCurrent("node-1", observeClaudePermission("--permission-mode acceptEdits"))).toBe(false);
|
||||
expect(missing.readCurrent("node-1")).toBeNull();
|
||||
} finally {
|
||||
bare.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,13 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
|
||||
|
||||
const CLAUDE_FLOOR_EFFECT = {
|
||||
runtime: "claude-code",
|
||||
axis: "permission",
|
||||
state: "observed",
|
||||
value: "acceptEdits",
|
||||
} as const;
|
||||
|
||||
function mockTmux(overrides?: {
|
||||
sendText?: (target: string, text: string) => Promise<TmuxResult>;
|
||||
sendKeys?: (target: string, keys: string[]) => Promise<TmuxResult>;
|
||||
@@ -85,7 +92,7 @@ describe("ClaudeResumeAdapter", () => {
|
||||
it("returns { ok: true } on success", async () => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux());
|
||||
const result = await adapter.resume("r99-demo1-lead", "claude_name", "my-session", "/repo");
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it("returns { ok: false, code: 'resume_failed' } on sendText failure", async () => {
|
||||
@@ -177,7 +184,7 @@ describe("ClaudeResumeAdapter", () => {
|
||||
|
||||
const result = await adapter.resume("r99-demo1-lead", "claude_name", "my-session", "/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
|
||||
expect(getPaneCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -204,7 +211,7 @@ describe("ClaudeResumeAdapter", () => {
|
||||
|
||||
const result = await adapter.resume("r99-demo1-lead", "claude_name", "my-session", "/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
// OPR.0.3.4.5 — regression guard behavior 05: CONSUMER human gate.
|
||||
@@ -278,7 +285,7 @@ describe("ClaudeResumeAdapter", () => {
|
||||
|
||||
const result = await adapter.resume("r99-demo1-lead", "claude_name", "my-session", "/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -477,6 +477,7 @@ describe("Claude Code runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "abc-123",
|
||||
resumeType: "claude_id",
|
||||
appliedLaunch: { runtime: "claude-code", axis: "permission", state: "observed", value: "acceptEdits" },
|
||||
});
|
||||
expect(tmux.sendKeys).toHaveBeenCalledTimes(2);
|
||||
expect(tmux.sendKeys).toHaveBeenNthCalledWith(2, "r01-impl", ["Enter"]);
|
||||
@@ -508,6 +509,7 @@ describe("Claude Code runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "abc-123",
|
||||
resumeType: "claude_id",
|
||||
appliedLaunch: { runtime: "claude-code", axis: "permission", state: "observed", value: "acceptEdits" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
|
||||
|
||||
const CODEX_FLOOR_EFFECT = {
|
||||
runtime: "codex",
|
||||
axis: "sandbox",
|
||||
state: "observed",
|
||||
value: "workspace-write",
|
||||
} as const;
|
||||
|
||||
function mockTmux(overrides?: {
|
||||
sendText?: (target: string, text: string) => Promise<TmuxResult>;
|
||||
sendKeys?: (target: string, keys: string[]) => Promise<TmuxResult>;
|
||||
@@ -91,7 +98,7 @@ describe("CodexResumeAdapter", () => {
|
||||
it("returns { ok: true } on success", async () => {
|
||||
const adapter = new CodexResumeAdapter(mockTmux());
|
||||
const result = await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CODEX_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it("returns { ok: false, code: 'resume_failed' } on failure", async () => {
|
||||
@@ -197,7 +204,7 @@ describe("CodexResumeAdapter", () => {
|
||||
|
||||
const result = await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CODEX_FLOOR_EFFECT });
|
||||
expect(getPaneCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -212,7 +219,7 @@ describe("CodexResumeAdapter", () => {
|
||||
|
||||
const result = await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CODEX_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it("probe sees `No saved session found` -> { ok: false, code: 'retry_fresh' }", async () => {
|
||||
@@ -283,7 +290,7 @@ describe("CodexResumeAdapter", () => {
|
||||
|
||||
const result = await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: CODEX_FLOOR_EFFECT });
|
||||
expect(attempt).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-ada
|
||||
import type { ProjectionPlan, ProjectionEntry } from "../src/domain/projection-planner.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const CODEX_FLOOR_EFFECT = {
|
||||
runtime: "codex",
|
||||
axis: "sandbox",
|
||||
state: "observed",
|
||||
value: "workspace-write",
|
||||
} as const;
|
||||
|
||||
function mockTmux(overrides?: Partial<TmuxAdapter>): TmuxAdapter {
|
||||
return {
|
||||
sendText: vi.fn(async () => ({ ok: true as const })),
|
||||
@@ -565,6 +572,7 @@ describe("Codex runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: CODEX_FLOOR_EFFECT,
|
||||
});
|
||||
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
|
||||
expect(sendText.mock.calls).toEqual([
|
||||
@@ -645,6 +653,7 @@ describe("Codex runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: CODEX_FLOOR_EFFECT,
|
||||
});
|
||||
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
|
||||
expect(sendText.mock.calls).toEqual([
|
||||
@@ -674,6 +683,7 @@ describe("Codex runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: CODEX_FLOOR_EFFECT,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -699,6 +709,7 @@ describe("Codex runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: CODEX_FLOOR_EFFECT,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -734,6 +745,7 @@ describe("Codex runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: CODEX_FLOOR_EFFECT,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -769,6 +781,7 @@ describe("Codex runtime adapter", () => {
|
||||
ok: true,
|
||||
resumeToken: "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: CODEX_FLOOR_EFFECT,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -894,7 +907,7 @@ describe("Codex runtime adapter", () => {
|
||||
|
||||
const result = await adapter.launchHarness(makeBinding(), { name: "dev-qa@test-rig", resumeToken: "sess-456" });
|
||||
|
||||
expect(result).toEqual({ ok: true, resumeToken: "sess-456", resumeType: "codex_id" });
|
||||
expect(result).toEqual({ ok: true, resumeToken: "sess-456", resumeType: "codex_id", appliedLaunch: CODEX_FLOOR_EFFECT });
|
||||
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
|
||||
expect(sendText.mock.calls).toEqual([
|
||||
["r01-qa", expectedResumeCommand()],
|
||||
|
||||
@@ -34,6 +34,7 @@ import { watchdogJobsSchema } from "../../src/db/migrations/031_watchdog_jobs.js
|
||||
import { occupantGenerationStampsSchema } from "../../src/db/migrations/063_occupant_generation_stamps.js";
|
||||
import { projectionManifestSchema } from "../../src/db/migrations/064_projection_manifest.js";
|
||||
import { watchdogTargetGenerationSchema } from "../../src/db/migrations/066_watchdog_target_generation.js";
|
||||
import { appliedLaunchObservationsSchema } from "../../src/db/migrations/069_applied_launch_observations.js";
|
||||
import { nodeCodexConfigProfileSchema } from "../../src/db/migrations/022_node_codex_config_profile.js";
|
||||
// PL-019: GET /api/rigs/:id/graph + GET /api/rigs/:rigId/nodes/:logicalId
|
||||
// now perform a read-side join over queue_items (in-progress qitem
|
||||
@@ -101,7 +102,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
@@ -202,6 +203,9 @@ export function createTestApp(
|
||||
// legacy `/healthz` `{ status: "ok" }` body.
|
||||
eventLoopMonitor?: import("../../src/domain/event-loop-monitor.js").EventLoopMonitor;
|
||||
routeTimingRecorder?: import("../../src/domain/route-timing-recorder.js").RouteTimingRecorder;
|
||||
permissionDriftObserver?: {
|
||||
diagnose(nodeId: string): import("../../src/domain/permission-drift.js").PermissionDriftDiagnostic | null;
|
||||
};
|
||||
/**
|
||||
* Agent Starter v1 vertical M2: optional real-fs upRouter for tests
|
||||
* that POST /api/up with a YAML spec on disk. Default behavior
|
||||
@@ -367,6 +371,7 @@ export function createTestApp(
|
||||
activityHookToken: opts?.activityHookToken,
|
||||
eventLoopMonitor: opts?.eventLoopMonitor,
|
||||
routeTimingRecorder: opts?.routeTimingRecorder,
|
||||
permissionDriftObserver: opts?.permissionDriftObserver,
|
||||
});
|
||||
return {
|
||||
app, rigRepo, sessionRegistry, eventBus, nodeLauncher, snapshotRepo,
|
||||
|
||||
@@ -373,7 +373,15 @@ describe("Lifecycle reboot/recovery scenario matrix (Tier 1)", () => {
|
||||
|
||||
const r = await adapter.resume("r99-worker", "codex_id", "tok-abc", "/tmp");
|
||||
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
appliedLaunch: {
|
||||
runtime: "codex",
|
||||
axis: "sandbox",
|
||||
state: "observed",
|
||||
value: "workspace-write",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("probe sees `No saved session found` → {ok:false, code:'retry_fresh'} (NOT silent ok:true)", async () => {
|
||||
|
||||
@@ -39,8 +39,9 @@ describe("0.5.2-07 A2-3 — model-fidelity enumeration guard", () => {
|
||||
|
||||
it("codex adapter: the resume branch passes the model into buildCodexResumeCore", () => {
|
||||
const src = read("adapters/codex-runtime-adapter.ts");
|
||||
// The LAUNCH resume call threads binding.launchPosture then the model as the final positional arg.
|
||||
expect(src).toMatch(/buildCodexResumeCore\([^;]*binding\.launchPosture,\s*model\)/);
|
||||
// The LAUNCH resume call threads binding.launchPosture, model, then the exact precomputed
|
||||
// posture segment. The latter prevents W3 observation from re-deciding policy.
|
||||
expect(src).toMatch(/buildCodexResumeCore\([^;]*binding\.launchPosture,\s*model,\s*postureArg\)/);
|
||||
});
|
||||
|
||||
it("claude adapter: every claude seat-launch template threads modelArg", () => {
|
||||
@@ -65,7 +66,7 @@ describe("0.5.2-07 A2-3 — model-fidelity enumeration guard", () => {
|
||||
|
||||
it("shared buildCodexResumeCore accepts a model param and emits -m before the resume subcommand", () => {
|
||||
const src = read("domain/native-resume-probe.ts");
|
||||
expect(src).toMatch(/model\?: string \| null,\n\): string/);
|
||||
expect(src).toMatch(/model\?: string \| null,\n\s*\/\*\*[^]*?\*\/\n\s*precomputedPostureArg\?: string,\n\): string/);
|
||||
expect(src).toMatch(/const modelArg = model \? ` -m \$\{shellQuote\(model\)\}`/);
|
||||
expect(src).toContain("`codex${profileOrPosture}${modelArg} resume ");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { observeClaudePermission } from "../src/domain/permission-drift.js";
|
||||
import { PermissionDriftObserver } from "../src/domain/permission-drift-observer.js";
|
||||
|
||||
describe("PermissionDriftObserver", () => {
|
||||
it("reads only the exact current generation and runtime-native effective surface", () => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const rigs = new RigRepository(db);
|
||||
const sessions = new SessionRegistry(db);
|
||||
const rig = rigs.createRig("observer-rig");
|
||||
const node = rigs.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/work/project" });
|
||||
sessions.registerClaimedSession(node.id, "dev-impl@observer-rig");
|
||||
new AppliedLaunchObservationStore(db).recordCurrent(node.id, observeClaudePermission("--permission-mode acceptEdits"));
|
||||
const readFile = vi.fn(() => JSON.stringify({ permissions: { defaultMode: "manual", allow: [], ask: [], deny: [] } }));
|
||||
const observer = new PermissionDriftObserver({
|
||||
db,
|
||||
fs: {
|
||||
readFile,
|
||||
cwdReadable: () => true,
|
||||
commandAvailable: () => true,
|
||||
claudePermissionModes: () => ["acceptEdits", "manual"],
|
||||
},
|
||||
now: () => new Date("2026-08-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(observer.diagnose(node.id)).toMatchObject({
|
||||
transport: { state: "healthy" },
|
||||
cwdRead: { state: "visible" },
|
||||
commandPath: { state: "available" },
|
||||
enforcement: {
|
||||
axis: "permission",
|
||||
state: "drift",
|
||||
expected: "acceptEdits",
|
||||
effective: { defaultMode: "manual" },
|
||||
sourcePath: "/work/project/.claude/settings.local.json",
|
||||
},
|
||||
});
|
||||
expect(readFile).toHaveBeenCalledWith("/work/project/.claude/settings.local.json");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diagnoseRuntimePosture,
|
||||
observeClaudePermission,
|
||||
observeCodexSandbox,
|
||||
observePiResourceTrust,
|
||||
parseClaudePermissionModes,
|
||||
renderPermissionDriftSummary,
|
||||
type PermissionDriftFs,
|
||||
} from "../src/domain/permission-drift.js";
|
||||
|
||||
function fsFixture(files: Record<string, string | Error>, cwdReadable: boolean | null = true): PermissionDriftFs {
|
||||
return {
|
||||
readFile(path) {
|
||||
const value = files[path];
|
||||
if (value === undefined) {
|
||||
const err = new Error(`missing: ${path}`) as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
if (value instanceof Error) throw value;
|
||||
return value;
|
||||
},
|
||||
cwdReadable: () => cwdReadable,
|
||||
commandAvailable: () => true,
|
||||
claudePermissionModes: () => ["acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan"],
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = "/tmp/w3-project";
|
||||
const settingsPath = `${cwd}/.claude/settings.local.json`;
|
||||
|
||||
describe("applied launch observations use the exact enforcing value", () => {
|
||||
it("preserves Claude permission vocabulary", () => {
|
||||
expect(observeClaudePermission("--permission-mode acceptEdits")).toEqual({
|
||||
runtime: "claude-code",
|
||||
axis: "permission",
|
||||
state: "observed",
|
||||
value: "acceptEdits",
|
||||
});
|
||||
expect(observeClaudePermission("--dangerously-skip-permissions").value).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("preserves Codex sandbox vocabulary and refuses to guess named-profile semantics", () => {
|
||||
expect(observeCodexSandbox(" -s workspace-write")).toMatchObject({ axis: "sandbox", state: "observed", value: "workspace-write" });
|
||||
expect(observeCodexSandbox(" -s danger-full-access")).toMatchObject({ axis: "sandbox", state: "observed", value: "danger-full-access" });
|
||||
expect(observeCodexSandbox(" -p cautious")).toMatchObject({ axis: "sandbox", state: "unknown", value: null, reason: "named_profile_unresolved" });
|
||||
});
|
||||
|
||||
it("preserves Pi resource-trust vocabulary and never calls it permission", () => {
|
||||
const observation = observePiResourceTrust("approve");
|
||||
expect(observation).toEqual({ runtime: "pi", axis: "resource_trust", state: "observed", value: "approve" });
|
||||
expect(JSON.stringify(observation)).not.toMatch(/permission/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("strict read-only effective observer", () => {
|
||||
it("derives Claude permission vocabulary from the live help shape", () => {
|
||||
expect(parseClaudePermissionModes([
|
||||
"--permission-mode <mode> Permission mode to use",
|
||||
" (choices: \"acceptEdits\", \"auto\", \"bypassPermissions\",",
|
||||
" \"manual\", \"dontAsk\", \"plan\")",
|
||||
].join("\n"))).toEqual(["acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan"]);
|
||||
expect(parseClaudePermissionModes("no permission surface")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a narrowed Claude project policy as drift with the exact file and independent axes", () => {
|
||||
const diagnostic = diagnoseRuntimePosture({
|
||||
runtime: "claude-code",
|
||||
cwd,
|
||||
applied: observeClaudePermission("--permission-mode acceptEdits"),
|
||||
fs: fsFixture({
|
||||
[settingsPath]: JSON.stringify({ permissions: { defaultMode: "manual", allow: ["Read(.)"], deny: ["Read(/tmp/**)"] } }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(diagnostic.transport.state).toBe("healthy");
|
||||
expect(diagnostic.cwdRead.state).toBe("visible");
|
||||
expect(diagnostic.commandPath.state).toBe("available");
|
||||
expect(diagnostic.enforcement).toMatchObject({
|
||||
axis: "permission",
|
||||
state: "drift",
|
||||
expected: "acceptEdits",
|
||||
sourcePath: settingsPath,
|
||||
});
|
||||
expect(diagnostic.enforcement.effective).toEqual({
|
||||
defaultMode: "manual",
|
||||
allow: ["Read(.)"],
|
||||
ask: [],
|
||||
deny: ["Read(/tmp/**)"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a matching Claude defaultMode as aligned", () => {
|
||||
const diagnostic = diagnoseRuntimePosture({
|
||||
runtime: "claude-code",
|
||||
cwd,
|
||||
applied: observeClaudePermission("--permission-mode acceptEdits"),
|
||||
fs: fsFixture({ [settingsPath]: JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }) }),
|
||||
});
|
||||
expect(diagnostic.enforcement).toMatchObject({ axis: "permission", state: "aligned", expected: "acceptEdits" });
|
||||
});
|
||||
|
||||
it("reports UNKNOWN-EFFECTIVE when live harness semantics cannot be resolved", () => {
|
||||
const diagnostic = diagnoseRuntimePosture({
|
||||
runtime: "claude-code",
|
||||
cwd,
|
||||
applied: observeClaudePermission("--permission-mode acceptEdits"),
|
||||
fs: {
|
||||
...fsFixture({ [settingsPath]: JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }) }),
|
||||
claudePermissionModes: () => null,
|
||||
},
|
||||
});
|
||||
expect(diagnostic.enforcement).toMatchObject({ state: "unknown", reason: "harness_semantics_unknown" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", {}, "settings_missing"],
|
||||
["malformed", { [settingsPath]: "{" }, "settings_unparseable"],
|
||||
["top-level array", { [settingsPath]: "[]" }, "settings_invalid_shape"],
|
||||
["scalar permissions", { [settingsPath]: JSON.stringify({ permissions: "denyAll" }) }, "permissions_invalid_shape"],
|
||||
["unsupported mode", { [settingsPath]: JSON.stringify({ permissions: { defaultMode: "futureMode" } }) }, "unsupported_default_mode"],
|
||||
["conflicting rule", { [settingsPath]: JSON.stringify({ permissions: { defaultMode: "acceptEdits", allow: ["Read(/**)"], deny: ["Read(/**)"] } }) }, "conflicting_rules"],
|
||||
])("keeps %s UNKNOWN-EFFECTIVE", (_name, files, reason) => {
|
||||
const diagnostic = diagnoseRuntimePosture({
|
||||
runtime: "claude-code",
|
||||
cwd,
|
||||
applied: observeClaudePermission("--permission-mode acceptEdits"),
|
||||
fs: fsFixture(files as Record<string, string>),
|
||||
});
|
||||
expect(diagnostic.enforcement).toMatchObject({ axis: "permission", state: "unknown", reason, sourcePath: settingsPath });
|
||||
});
|
||||
|
||||
it("keeps unreadable cwd/file and missing command separate from permission truth", () => {
|
||||
const denied = Object.assign(new Error("EACCES"), { code: "EACCES" });
|
||||
const diagnostic = diagnoseRuntimePosture({
|
||||
runtime: "claude-code",
|
||||
cwd,
|
||||
applied: observeClaudePermission("--permission-mode acceptEdits"),
|
||||
fs: {
|
||||
...fsFixture({ [settingsPath]: denied }, false),
|
||||
commandAvailable: () => false,
|
||||
},
|
||||
});
|
||||
expect(diagnostic.cwdRead.state).toBe("denied");
|
||||
expect(diagnostic.commandPath.state).toBe("missing");
|
||||
expect(diagnostic.enforcement).toMatchObject({ state: "unknown", reason: "settings_unreadable" });
|
||||
});
|
||||
|
||||
it("does not render Pi resource trust in a permissions column", () => {
|
||||
const diagnostic = diagnoseRuntimePosture({
|
||||
runtime: "pi",
|
||||
cwd,
|
||||
applied: observePiResourceTrust("no-approve"),
|
||||
fs: fsFixture({}),
|
||||
});
|
||||
const text = renderPermissionDriftSummary(diagnostic);
|
||||
expect(text).toContain("resource trust");
|
||||
expect(text).not.toMatch(/permission(?:s)?\s*:/i);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ import type { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { NodeBinding, RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import { claudePostureFlag, codexPostureArg, piTrust } from "../src/adapters/yolo-mode.js";
|
||||
import { observeClaudePermission } from "../src/domain/permission-drift.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
|
||||
const CUSTOM_POLICY = `---
|
||||
policy_schema_version: 1
|
||||
@@ -128,7 +130,10 @@ describe("F1 — rig-level custom provenance is RESTART-COMPLETE", () => {
|
||||
const tmux = mockTmux();
|
||||
const claudeResume = {
|
||||
canResume: vi.fn((t: string | null) => t === "claude_id" || t === "claude_name"),
|
||||
resume: vi.fn(async (): Promise<ResumeResult> => ({ ok: true })),
|
||||
resume: vi.fn(async (): Promise<ResumeResult> => ({
|
||||
ok: true,
|
||||
appliedLaunch: observeClaudePermission("--dangerously-skip-permissions"),
|
||||
})),
|
||||
} as unknown as ClaudeResumeAdapter;
|
||||
const orch = new RestoreOrchestrator({
|
||||
db: db2, rigRepo: rigRepo2, sessionRegistry: sessionRegistry2, eventBus: eventBus2,
|
||||
@@ -145,6 +150,12 @@ describe("F1 — rig-level custom provenance is RESTART-COMPLETE", () => {
|
||||
const call = (claudeResume.resume as ReturnType<typeof vi.fn>).mock.calls.find((c) => c[2] === "tok-123");
|
||||
expect(call, "organic seat resume should have been attempted").toBeDefined();
|
||||
expect(call![4]).toBe("full_bypass"); // 5th arg = resolvedPosture from RIG provenance
|
||||
expect(new AppliedLaunchObservationStore(db2).readCurrent(organic.id)).toMatchObject({
|
||||
runtime: "claude-code",
|
||||
axis: "permission",
|
||||
state: "observed",
|
||||
value: "bypassPermissions",
|
||||
});
|
||||
db2.close();
|
||||
});
|
||||
|
||||
@@ -309,9 +320,15 @@ describe("F4 — REAL adapter command pins (floor + full_bypass on every launch
|
||||
for (const [posture, expected] of [["full_bypass", "--dangerously-skip-permissions"], ["floor", "--permission-mode acceptEdits"]] as const) {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: claudeMockFs(), sessionIdFactory: () => "11111111-1111-4111-8111-111111111111" });
|
||||
await adapter.launchHarness(binding(posture), { name: "dev-impl@test-rig" });
|
||||
const result = await adapter.launchHarness(binding(posture), { name: "dev-impl@test-rig" });
|
||||
const cmd = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(cmd).toContain(expected);
|
||||
expect(result.ok && result.appliedLaunch).toMatchObject({
|
||||
runtime: "claude-code",
|
||||
axis: "permission",
|
||||
state: "observed",
|
||||
value: posture === "floor" ? "acceptEdits" : "bypassPermissions",
|
||||
});
|
||||
if (posture === "floor") expect(cmd).not.toContain("--dangerously-skip-permissions");
|
||||
}
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
@@ -322,18 +339,20 @@ describe("F4 — REAL adapter command pins (floor + full_bypass on every launch
|
||||
// fresh
|
||||
for (const [posture, expected, absent] of [["full_bypass", " -s danger-full-access", ""], ["floor", " -s workspace-write", "danger-full-access"]] as const) {
|
||||
const tmux = mockTmux();
|
||||
await new CodexRuntimeAdapter({ sleep: async () => {}, tmux, fsOps: codexFs as never }).launchHarness(binding(posture), { name: "dev-qa@test-rig" });
|
||||
const result = await new CodexRuntimeAdapter({ sleep: async () => {}, tmux, fsOps: codexFs as never }).launchHarness(binding(posture), { name: "dev-qa@test-rig" });
|
||||
const cmd = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(cmd).toContain(expected);
|
||||
expect(result.ok && result.appliedLaunch).toMatchObject({ axis: "sandbox", state: "observed", value: posture === "floor" ? "workspace-write" : "danger-full-access" });
|
||||
if (absent) expect(cmd).not.toContain(absent);
|
||||
}
|
||||
// native fork (the Slice-02 helper remains the only flag translator)
|
||||
for (const [posture, expected] of [["full_bypass", " -s danger-full-access"], ["floor", " -s workspace-write"]] as const) {
|
||||
const tmux = mockTmux();
|
||||
await new CodexRuntimeAdapter({ sleep: async () => {}, tmux, fsOps: codexFs as never }).launchHarness(binding(posture), { name: "dev-qa@test-rig", forkSource: { kind: "native_id", value: "parent-thread-1" } as never });
|
||||
const result = await new CodexRuntimeAdapter({ sleep: async () => {}, tmux, fsOps: codexFs as never }).launchHarness(binding(posture), { name: "dev-qa@test-rig", forkSource: { kind: "native_id", value: "parent-thread-1" } as never });
|
||||
const cmd = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(cmd).toContain("fork");
|
||||
expect(cmd).toContain(expected);
|
||||
if (result.ok) expect(result.appliedLaunch).toMatchObject({ axis: "sandbox", value: posture === "floor" ? "workspace-write" : "danger-full-access" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -344,11 +363,12 @@ describe("F4 — REAL adapter command pins (floor + full_bypass on every launch
|
||||
for (const [posture, expected] of [["full_bypass", "--dangerously-skip-permissions"], ["floor", "--permission-mode acceptEdits"]] as const) {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new RealClaudeResume(tmux);
|
||||
await adapter.resume("s1", "claude_id", "tok-1", "/w", posture);
|
||||
const result = await adapter.resume("s1", "claude_id", "tok-1", "/w", posture);
|
||||
const cmd = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(cmd).toContain("claude");
|
||||
expect(cmd).toContain("--resume");
|
||||
expect(cmd).toContain(expected);
|
||||
expect(result.ok && result.appliedLaunch).toMatchObject({ axis: "permission", value: posture === "floor" ? "acceptEdits" : "bypassPermissions" });
|
||||
}
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
@@ -505,11 +525,13 @@ describe("GF2 — the COMPLETE production-altitude launch/restore matrix", () =>
|
||||
for (const [posture, expected, absent] of [["full_bypass", " -s danger-full-access", ""], ["floor", " -s workspace-write", "danger-full-access"]] as const) {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new RealCodexResume(tmux, { sleep: async () => {} });
|
||||
await adapter.resume("s1", "codex_id", "thread-1", "/w", null, posture);
|
||||
(tmux.getPaneCommand as ReturnType<typeof vi.fn>).mockResolvedValue("codex");
|
||||
const result = await adapter.resume("s1", "codex_id", "thread-1", "/w", null, posture);
|
||||
const cmd = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string;
|
||||
expect(cmd).toContain("codex");
|
||||
expect(cmd).toContain("resume");
|
||||
expect(cmd).toContain(expected);
|
||||
expect(result.ok && result.appliedLaunch).toMatchObject({ axis: "sandbox", value: posture === "floor" ? "workspace-write" : "danger-full-access" });
|
||||
if (absent) expect(cmd).not.toContain(absent);
|
||||
}
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
|
||||
@@ -18,6 +18,12 @@ const STATE_ROOT = "/openrig-home/state/pi";
|
||||
const RUNNER = "/daemon-dist/adapters/pi-runner.js";
|
||||
const SESSION = "devpi-a@some-rig";
|
||||
const SESSION_FILE = `${STATE_ROOT}/${SESSION}/sessions/2026-07-06T10-00-00_0197a2f0.jsonl`;
|
||||
const PI_FLOOR_EFFECT = {
|
||||
runtime: "pi",
|
||||
axis: "resource_trust",
|
||||
state: "observed",
|
||||
value: "no-approve",
|
||||
} as const;
|
||||
|
||||
function mockTmux(overrides?: {
|
||||
sendText?: (target: string, text: string) => Promise<TmuxResult>;
|
||||
@@ -216,7 +222,7 @@ describe("PiRuntimeAdapter.launchHarness", () => {
|
||||
const adapter = adapterWith(fs, mockTmux({ sendText }));
|
||||
|
||||
const result = await adapter.launchHarness(binding, { name: SESSION });
|
||||
expect(result).toEqual({ ok: true, resumeToken: SESSION_FILE, resumeType: "pi_session_file" });
|
||||
expect(result).toEqual({ ok: true, resumeToken: SESSION_FILE, resumeType: "pi_session_file", appliedLaunch: PI_FLOOR_EFFECT });
|
||||
// Seat isolation dirs were created.
|
||||
expect(fs.dirs.has(piSeatPaths(STATE_ROOT, SESSION).agentDir)).toBe(true);
|
||||
expect(fs.dirs.has(piSeatPaths(STATE_ROOT, SESSION).sessionsDir)).toBe(true);
|
||||
@@ -255,7 +261,7 @@ describe("PiRuntimeAdapter.launchHarness", () => {
|
||||
});
|
||||
const adapter = adapterWith(fs, mockTmux({ sendText }));
|
||||
const result = await adapter.launchHarness(binding, { name: SESSION, resumeToken: SESSION_FILE });
|
||||
expect(result).toEqual({ ok: true, resumeToken: SESSION_FILE, resumeType: "pi_session_file" });
|
||||
expect(result).toEqual({ ok: true, resumeToken: SESSION_FILE, resumeType: "pi_session_file", appliedLaunch: PI_FLOOR_EFFECT });
|
||||
expect(sendText).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -287,7 +293,7 @@ describe("PiRuntimeAdapter.launchHarness", () => {
|
||||
const result = await adapter.launchHarness(binding, {
|
||||
name: SESSION, forkSource: { kind: "native_id", value: parent },
|
||||
});
|
||||
expect(result).toEqual({ ok: true, resumeToken: child, resumeType: "pi_session_file" });
|
||||
expect(result).toEqual({ ok: true, resumeToken: child, resumeType: "pi_session_file", appliedLaunch: PI_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it("fork FAILS if the runner reports the parent file as the session (post-fork token rule)", async () => {
|
||||
@@ -405,7 +411,7 @@ describe("PiResumeAdapter", () => {
|
||||
});
|
||||
const a = resumeAdapter(fs, mockTmux({ sendText }));
|
||||
const result = await a.resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: PI_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it("fails honestly when the runner reports a DIFFERENT session file than requested", async () => {
|
||||
@@ -456,7 +462,7 @@ describe("launch-attempt scoping — stale artifacts never count", () => {
|
||||
});
|
||||
const adapter = adapterWith(fs, mockTmux({ sendText }));
|
||||
const result = await adapter.launchHarness(binding, { name: SESSION });
|
||||
expect(result).toEqual({ ok: true, resumeToken: NEW_FILE, resumeType: "pi_session_file" });
|
||||
expect(result).toEqual({ ok: true, resumeToken: NEW_FILE, resumeType: "pi_session_file", appliedLaunch: PI_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it("fresh launch ignores a PRE-EXISTING exited sidecar (no instant attention_required)", async () => {
|
||||
@@ -542,7 +548,7 @@ describe("PiResumeAdapter — stale-artifact scoping", () => {
|
||||
});
|
||||
const a = resumeAdapter(fs, mockTmux({ sendText }));
|
||||
const result = await a.resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: PI_FLOOR_EFFECT });
|
||||
});
|
||||
|
||||
it("this attempt's sidecar ready WITHOUT a sessionFile is not proof (no optional match)", async () => {
|
||||
@@ -604,7 +610,7 @@ describe("durable catch-up cursor survives launch-attempt resets", () => {
|
||||
pollMs: 1, maxWaitMs: 5, sleep: async () => {},
|
||||
});
|
||||
const result = await a.resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, appliedLaunch: PI_FLOOR_EFFECT });
|
||||
expect(sendText).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { watchdogHistorySchema } from "../src/db/migrations/032_watchdog_history.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import { observeCodexSandbox } from "../src/domain/permission-drift.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
|
||||
describe("SeatHandoverService", () => {
|
||||
let db: Database.Database;
|
||||
@@ -60,7 +62,12 @@ describe("SeatHandoverService", () => {
|
||||
capturePaneScreen = vi.fn(async () => "predecessor screen tail");
|
||||
// B1 — a fresh successor is launched into a live agent (launchHarness +
|
||||
// readiness) with a scraped resume token (B2 launched-mode).
|
||||
launchHarness = vi.fn(async () => ({ ok: true, resumeToken: "codex-launch-tok", resumeType: "codex_id" }));
|
||||
launchHarness = vi.fn(async () => ({
|
||||
ok: true,
|
||||
resumeToken: "codex-launch-tok",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: observeCodexSandbox(" -s workspace-write"),
|
||||
}));
|
||||
checkReady = vi.fn(async () => ({ ready: true }));
|
||||
// B2 — discovered-mode derive-helper deps (Codex thread-id capturer by default).
|
||||
readSidecar = vi.fn(() => ({ ok: true, data: { session_id: "claude-sid-123" } }));
|
||||
@@ -425,6 +432,26 @@ describe("SeatHandoverService", () => {
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it("persists the successor's applied effect only after the handover generation is minted", async () => {
|
||||
const { node } = seedSeat({ runtime: "codex" });
|
||||
const predecessor = sessionRegistry.currentOccupantTenure(node.id)!;
|
||||
const result = await service.handover({
|
||||
seatRef: "dev-impl@seat-rig",
|
||||
reason: "context-wall",
|
||||
source: "fresh",
|
||||
operator: "orch-lead@seat-rig",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const successor = sessionRegistry.currentOccupantTenure(node.id)!;
|
||||
expect(successor.generationUuid).not.toBe(predecessor.generationUuid);
|
||||
expect(new AppliedLaunchObservationStore(db).readCurrent(node.id)).toMatchObject({
|
||||
generationUuid: successor.generationUuid,
|
||||
runtime: "codex",
|
||||
axis: "sandbox",
|
||||
value: "workspace-write",
|
||||
});
|
||||
});
|
||||
|
||||
it("MONEY PROOF (0.5.2-07): a SPEC-pinned model seat's handover launches the successor on the SPEC model — the REAL lookupNode→createSuccessor→launchHarness path, not an injected node", async () => {
|
||||
// Same shape as Seam B: asserts the binding the REAL launchHarness received, driven end-to-end by
|
||||
// service.handover(). The launcher-level money proof injects node.model directly and cannot catch
|
||||
|
||||
@@ -929,6 +929,16 @@ describe("session_source Pi rows (OPR.0.4.6.PI1)", () => {
|
||||
expect(typedCommand).toMatch(/--(no-)?approve/); // explicit trust, always
|
||||
|
||||
// Post-fork token rule: the NEW child file is persisted, never the parent.
|
||||
expect(result).toEqual({ ok: true, resumeToken: PI_CHILD, resumeType: "pi_session_file" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
resumeToken: PI_CHILD,
|
||||
resumeType: "pi_session_file",
|
||||
appliedLaunch: {
|
||||
runtime: "pi",
|
||||
axis: "resource_trust",
|
||||
state: "observed",
|
||||
value: "no-approve",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -596,6 +596,31 @@ describe("Session routes", () => {
|
||||
expect(body[0].canonicalSessionName).toBe("dev-impl@test-rig");
|
||||
});
|
||||
|
||||
it("keeps permission observation off the fleet list and attaches it only to single-node detail", async () => {
|
||||
const diagnose = vi.fn(() => ({
|
||||
transport: { state: "healthy" as const },
|
||||
cwdRead: { state: "visible" as const },
|
||||
commandPath: { state: "available" as const },
|
||||
enforcement: { axis: "sandbox" as const, state: "aligned" as const, expected: "workspace-write", effective: "workspace-write", sourcePath: null },
|
||||
observedAt: "2026-08-08T00:00:00.000Z",
|
||||
}));
|
||||
const { app, rigRepo, sessionRegistry } = createTestApp(db, { permissionDriftObserver: { diagnose } });
|
||||
const rig = rigRepo.createRig("test-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "codex" });
|
||||
sessionRegistry.registerClaimedSession(node.id, "dev-impl@test-rig");
|
||||
|
||||
const list = await app.request(`/api/rigs/${rig.id}/nodes?full=true`);
|
||||
expect(list.status).toBe(200);
|
||||
expect((await list.json())[0].permissionDrift).toBeUndefined();
|
||||
expect(diagnose).not.toHaveBeenCalled();
|
||||
|
||||
const detail = await app.request(`/api/rigs/${rig.id}/nodes/${encodeURIComponent("dev.impl")}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect((await detail.json()).permissionDrift).toMatchObject({ enforcement: { axis: "sandbox", state: "aligned" } });
|
||||
expect(diagnose).toHaveBeenCalledOnce();
|
||||
expect(diagnose).toHaveBeenCalledWith(node.id);
|
||||
});
|
||||
|
||||
it("GET /api/rigs/:rigId/nodes includes read-only agent activity evidence", async () => {
|
||||
const tmux = {
|
||||
hasSession: vi.fn(async () => true),
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { ProjectionPlan } from "../src/domain/projection-planner.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { StartupAction } from "../src/domain/types.js";
|
||||
import { deriveOriented } from "../src/domain/startup-proof.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { observeClaudePermission } from "../src/domain/permission-drift.js";
|
||||
|
||||
// -- Mocks --
|
||||
|
||||
@@ -157,6 +159,34 @@ describe("StartupOrchestrator", () => {
|
||||
expect(row.startup_completed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it("records the exact adapter-returned launch effect only after successful managed launch", async () => {
|
||||
const seed = seedSession();
|
||||
const appliedLaunch = observeClaudePermission("--permission-mode acceptEdits");
|
||||
const adapter = mockAdapter({ launchHarness: vi.fn(async () => ({ ok: true, appliedLaunch })) });
|
||||
const result = await createOrchestrator().startNode(makeInput(seed, { adapter }));
|
||||
expect(result.ok).toBe(true);
|
||||
expect(new AppliedLaunchObservationStore(db).readCurrent(seed.nodeId)).toMatchObject(appliedLaunch);
|
||||
});
|
||||
|
||||
it("does not record an attempted effect when the managed launch fails", async () => {
|
||||
const seed = seedSession();
|
||||
const adapter = mockAdapter({ launchHarness: vi.fn(async () => ({ ok: false, error: "provider refused" })) });
|
||||
const result = await createOrchestrator().startNode(makeInput(seed, { adapter }));
|
||||
expect(result.ok).toBe(false);
|
||||
expect(new AppliedLaunchObservationStore(db).readCurrent(seed.nodeId)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a successful provider launch successful when observation persistence is unavailable", async () => {
|
||||
const seed = seedSession();
|
||||
db.exec("DROP TABLE applied_launch_observations");
|
||||
const adapter = mockAdapter({
|
||||
launchHarness: vi.fn(async () => ({ ok: true, appliedLaunch: observeClaudePermission("--permission-mode acceptEdits") })),
|
||||
});
|
||||
const result = await createOrchestrator().startNode(makeInput(seed, { adapter }));
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.startupStatus).toBe("ready");
|
||||
});
|
||||
|
||||
// T3: delivery failure transitions to failed
|
||||
it("delivery failure transitions to failed", async () => {
|
||||
const seed = seedSession();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
@@ -36,12 +36,13 @@ describe("whoami routes", () => {
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
function createApp() {
|
||||
function createApp(permissionDriftObserver?: { diagnose(nodeId: string): unknown }) {
|
||||
const transcriptStore = new TranscriptStore({ transcriptsRoot: "/tmp/transcripts", enabled: true });
|
||||
const svc = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("whoamiService" as never, svc);
|
||||
c.set("permissionDriftObserver" as never, permissionDriftObserver);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/whoami", whoamiRoutes());
|
||||
@@ -82,6 +83,31 @@ describe("whoami routes", () => {
|
||||
expect(body.identity.logicalId).toBe("dev.impl");
|
||||
});
|
||||
|
||||
it("runs the permission observer only for the explicit current-seat diagnostic", async () => {
|
||||
const { node } = seedRig();
|
||||
const diagnose = vi.fn(() => ({
|
||||
transport: { state: "healthy" },
|
||||
cwdRead: { state: "visible" },
|
||||
commandPath: { state: "available" },
|
||||
enforcement: { axis: "permission", state: "drift", expected: "acceptEdits", effective: { defaultMode: "denyAll", allow: [], ask: [], deny: [] }, sourcePath: "/work/.claude/settings.local.json" },
|
||||
observedAt: "2026-08-08T00:00:00.000Z",
|
||||
}));
|
||||
const app = createApp({ diagnose });
|
||||
|
||||
const ordinary = await app.request(`/api/whoami?nodeId=${node.id}`);
|
||||
expect(ordinary.status).toBe(200);
|
||||
expect((await ordinary.json()).permissionDrift).toBeUndefined();
|
||||
expect(diagnose).not.toHaveBeenCalled();
|
||||
|
||||
const diagnostic = await app.request(`/api/whoami?nodeId=${node.id}&diagnostics=permission`);
|
||||
expect(diagnostic.status).toBe(200);
|
||||
expect((await diagnostic.json()).permissionDrift).toMatchObject({
|
||||
enforcement: { state: "drift", sourcePath: "/work/.claude/settings.local.json" },
|
||||
});
|
||||
expect(diagnose).toHaveBeenCalledOnce();
|
||||
expect(diagnose).toHaveBeenCalledWith(node.id);
|
||||
});
|
||||
|
||||
it("GET /api/whoami with no params returns 400", async () => {
|
||||
seedRig();
|
||||
const app = createApp();
|
||||
|
||||
Reference in new issue
Block a user