feat: surface generation-scoped permission drift

This commit is contained in:
v-openrig-build committed 2026-08-09 13:10:47 +00:00
1 parent 3614a956ba
commit e0764328ab
42 files changed
+1275 -83

No files matched your search

+117 -17
View File
@@ -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;
}
+87 -6
View File
@@ -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 /);
});
+3 -2
View File
@@ -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> {
+9 -1
View File
@@ -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 {
+6 -2
View File
@@ -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> {
+3 -1
View File
@@ -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 };
}
/**
+2
View File
@@ -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 =
+10
View File
@@ -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) {
+7
View File
@@ -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) {
+6
View File
@@ -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();
}
});
});
+11 -4
View File
@@ -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" },
});
});
+11 -4
View File
@@ -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()],
+6 -1
View File
@@ -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();
+28 -2
View File
@@ -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();