mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 18:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0107d6e788 | ||
|
|
5a52230e95 | ||
|
|
50f58dfaca | ||
|
|
f6ba977092 | ||
|
|
12ca636227 |
No files matched your search
@@ -84,14 +84,12 @@ Shared test vectors are in `schemas/fixtures/identity-vectors.json`.
|
||||
|
||||
`seats` gives a `pod.member` the runtimes it may use, and the profile each runtime uses. A member it doesn't list keeps
|
||||
the runtime `rig.yaml` gives it. `presets` names full mappings, and `recommended` names one of them, which must be
|
||||
`rig.yaml` as written. `rig bundle check` reports a file that breaks these rules as a finding, and
|
||||
`rig bundle configurations` names the problem instead of listing.
|
||||
`rig.yaml` as written. `rig bundle create`, `rig bundle configurations` and `rig bundle check` refuse a file that breaks
|
||||
these rules.
|
||||
|
||||
`--preset` and `--seat` choose a configuration (see the [rig bundle reference](rig-bundle.md)). Any mix of each seat's
|
||||
declared runtimes can be built, not only the presets. A runtime a seat doesn't declare, or a choice from a file that
|
||||
breaks these rules, is refused, and nothing is built. Without either flag, `rig bundle create`, and `rig bundle inspect`,
|
||||
`rig bundle install` and `rig up` of a GitHub folder link, use `rig.yaml` as written and don't check
|
||||
`configurations.yaml`. The built archive's manifest records
|
||||
declared runtimes can be built, not only the presets; a runtime a seat doesn't declare is refused, and nothing is built.
|
||||
Without either flag, create builds `rig.yaml` as written. The built archive's manifest records
|
||||
`configuration: { id, preset }` outside the package digest; `preset` is there only when `--preset` or `--seat` chose a
|
||||
mapping that matches a declared preset.
|
||||
|
||||
|
||||
@@ -232,7 +232,6 @@ With `needs-me.read` supported:
|
||||
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
|
||||
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
|
||||
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
|
||||
- **`GET /api/workflow/list`** accepts exact `project`, `mission`, `slice`, `rig` (the bound rig), and `name` (the workflow name) filters, repeatable `tag` filters on the instance's free-form scope tags, and `limit` from 1 to 10,000. Filters combine with AND and apply before the limit; results keep their existing oldest-first order. Instances without the requested scope or binding do not match. Without these filters, existing list behavior is unchanged.
|
||||
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
|
||||
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
|
||||
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
|
||||
|
||||
@@ -324,90 +324,6 @@ envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after
|
||||
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
|
||||
runtimes reject it.
|
||||
|
||||
For an explicit filesystem policy, use `filesystem` **instead of** `sandbox` and its
|
||||
workspace-write fields. This is the same per-seat selection and audited setter:
|
||||
|
||||
```yaml
|
||||
cwd: /workspace/shared
|
||||
env:
|
||||
CODEX_HOME: /accounts/codex # existing account home; never copied
|
||||
codex_permissions:
|
||||
approval: never
|
||||
filesystem:
|
||||
":minimal": read
|
||||
":workspace_roots": write
|
||||
"/workspace/shared/private": deny
|
||||
network_access: false
|
||||
```
|
||||
|
||||
The mapping accepts native `read`, `write` and `deny` values at absolute paths,
|
||||
`~/path`, or `:root`, `:minimal`, `:workspace_roots` (alias `:project_roots`),
|
||||
`:tmpdir` and `:slash_tmp`.
|
||||
It must be nonempty; nested subpath maps and arbitrary Codex settings are not part
|
||||
of this option. Omitted network access becomes `false`. Include the read access
|
||||
your tools and installed Codex executable need; this custom profile adds no
|
||||
workspace, temporary, queue-state, seat-folder or Git-directory write grants of
|
||||
its own. Native Codex decides whether the host can enforce the supplied rules.
|
||||
Filesystem approval accepts `never` or `on-request`; the retired `untrusted`
|
||||
approval is rejected before the selection is saved.
|
||||
|
||||
OpenRig emits the policy as invocation-local `permissions.openrig-seat` and
|
||||
`default_permissions` overrides, with the selected approval. It writes no policy
|
||||
file, so two seats can share both `CODEX_HOME` and `cwd` without overwriting each
|
||||
other. Clearing the selection changes only that seat's record. Existing
|
||||
account-scoped `codex_config_fragment` resources and their root-key rejection
|
||||
stay unchanged.
|
||||
|
||||
Before dispatch, OpenRig compares Codex's native configuration with the seat's
|
||||
selection. Positive evidence of access widening, a conflicting profile or
|
||||
approval choice, an excluding managed allowlist, or a missing/empty selected
|
||||
profile refuses launch. Compatible managed defaults and allowlists, harmless
|
||||
descriptions and provably narrower flat rules are accepted. Legacy sandbox
|
||||
settings are inert under the invocation's explicit permission-profile selection.
|
||||
Null fields are treated as absent, including unfamiliar optional fields.
|
||||
Managed `defaultPermissions` is a fallback, not a forced selection; the
|
||||
effective profile and managed allowlist determine whether the agent's choice holds.
|
||||
|
||||
A timeout, reader failure, unfamiliar representation or unsupported managed
|
||||
requirement continues with the authored flags and reports `policy not verified:
|
||||
<reason>` in the launch observation and status. The policy is never dropped or
|
||||
swapped, and account/project settings are never edited to make the check pass.
|
||||
Absent selection or approval fields, unfamiliar approval strings or profile formats,
|
||||
and unclear overlap between native selectors and authored path rules are also unverified.
|
||||
An authored selector that may cover an extra grant is considered before a literal
|
||||
ancestor would reject that grant, except that an explicit non-root deny ancestor
|
||||
always takes precedence over possible selector coverage. A full-root deny, written
|
||||
as `/` or `:root`, is a default narrower grants may refine: possible authored
|
||||
selector coverage stays unverified, while a proven outside grant still refuses.
|
||||
Codex 0.167.2's `:minimal` adds system read defaults, not `/home` or `/Users`
|
||||
user-data coverage; extra grants there under a root deny or with no root rule still refuse unless
|
||||
another authored rule may cover them. Other minimal-path coverage stays unexpanded.
|
||||
Duplicate aliases from a
|
||||
lower layer leave their own coverage unverified; they do not hide a known widening
|
||||
at another path.
|
||||
Inconclusive coverage warnings name the extra rule and access. An excluding managed
|
||||
allowlist is reported before an effective selection or approval mismatch.
|
||||
Concrete refusals name the conflicting rule and offer a remedy; handover preserves
|
||||
that remedy while leaving the predecessor running.
|
||||
Without an explicit `CODEX_HOME`, OpenRig resolves the normal account home and
|
||||
pins it for both the read and launch; an unresolvable home is reported as
|
||||
unverified. A named `codex_config_profile` is retained but unverified because the
|
||||
native reader cannot load that layer on this path. A `launch_wrapper` is also
|
||||
retained and unverified: it may change the effective home/configuration, so a
|
||||
read without that wrapper cannot certify the launch.
|
||||
|
||||
The filesystem selection replaces permission-mode and team/kernel defaults,
|
||||
including ambient YOLO. Fresh launches, both resume paths and restore emit the
|
||||
same selection. Hand-resuming from the command shown by `rig ps` does not yet
|
||||
carry this filesystem policy. A rebuild uses the saved filesystem policy ahead of an observed
|
||||
predecessor sandbox; clearing or replacing the policy after a snapshot takes
|
||||
effect on restore. Status reports the plan and its limits, not observed native
|
||||
enforcement. Handover checks the successor's filesystem selection before
|
||||
signaling the predecessor; a concrete conflict leaves that predecessor running.
|
||||
Configuration can change between the preflight and native startup;
|
||||
the read and launch are not atomic. Codex's sandbox governs local command
|
||||
execution, not every connector, account or external service.
|
||||
|
||||
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
|
||||
audited permission command:
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Command } from "commander";
|
||||
import { InvalidArgumentError } from "commander";
|
||||
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "./client.js";
|
||||
import { blockedConnectionGuidance, isBlockedConnectionCode } from "./daemon-reachability.js";
|
||||
import { resetSenderSession } from "./sender-identity.js";
|
||||
|
||||
/**
|
||||
* Commander option parser — a positive integer (>= 1). Rejects negative, zero,
|
||||
@@ -175,9 +176,14 @@ export async function runProgram(program: Command, argv: string[], io: RunProgra
|
||||
};
|
||||
suppressErr(program);
|
||||
|
||||
// Each run starts clean: an earlier in-process run's failure isn't this run's status, and its sender isn't this run's.
|
||||
process.exitCode = undefined;
|
||||
resetSenderSession();
|
||||
try {
|
||||
await program.parseAsync(argv);
|
||||
return 0;
|
||||
// An action's own failure sets process.exitCode. It's returned, not passed to process.exit(), which can drop output
|
||||
// still being written to a pipe; the process ends with process.exitCode.
|
||||
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
||||
} catch (e) {
|
||||
if (isCleanCommanderExit(e)) return 0;
|
||||
// Response-integrity: render daemon transport failures honestly (3-part, both
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
|
||||
import { resolveSenderSession, type BindingCheck, type TmuxIdentityReader } from "./sender-identity.js";
|
||||
|
||||
export function terminalAuthHeaders(): Record<string, string> {
|
||||
const token = resolveTerminalToken();
|
||||
@@ -35,8 +36,10 @@ export const OCCUPANT_GENERATION_HEADER = "X-OpenRig-Occupant-Generation";
|
||||
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand. A client built for one
|
||||
* authenticated caller (`sessionName`) carries that caller's seat instead of the process env.
|
||||
*/
|
||||
export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: string): Record<string, string> {
|
||||
const session = (sessionName ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"))?.trim();
|
||||
export function senderIdentityHeaders(
|
||||
originSelfHostId?: string, sessionName?: string, tmuxReader?: TmuxIdentityReader, isBound?: BindingCheck,
|
||||
): Record<string, string> {
|
||||
const session = (sessionName ?? resolveSenderSession(tmuxReader, isBound))?.trim();
|
||||
if (!session) return {};
|
||||
// Preserve qualified senders; append only a locally derived instance id.
|
||||
const value =
|
||||
|
||||
@@ -17,6 +17,7 @@ import { omittedReadField, readView } from "../read-view.js";
|
||||
import { buildWriteReceipt, compactOutputEnabled, type ReceiptContext } from "../queue-receipt.js";
|
||||
import { TRANSITIONS_SHOWN, compactShow, compactTransitions, showEmptyNotice, transitionsNotice } from "../queue-read-compact.js";
|
||||
import { codeResultOf, planItemArtifact, type FileRead, type SharedRoot } from "../queue-artifact.js";
|
||||
import { resolveSenderSession } from "../sender-identity.js";
|
||||
|
||||
/**
|
||||
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
|
||||
@@ -347,7 +348,9 @@ async function withClient<T>(
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (isIndeterminateRemoteWrite(status, body)) {
|
||||
if (status >= 400 && body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
|
||||
&& (body as { outcome?: unknown }).outcome === "indeterminate") {
|
||||
console.error("The write outcome is INDETERMINATE if the request may have reached a daemon — reconcile by ID before any retry.");
|
||||
}
|
||||
if (json) {
|
||||
@@ -461,34 +464,6 @@ function writeReceipt(full: boolean | undefined, ctx: ReceiptContext): ReceiptCo
|
||||
return !full && compactOutputEnabled() ? ctx : null;
|
||||
}
|
||||
|
||||
/** A handoff whose outcome is unknown may have committed. This names the source row to read before any retry, on the
|
||||
* daemon the command used, and where a successor would be. It never says the handoff failed, names a successor id, or
|
||||
* asks for the handoff to be repeated. */
|
||||
function handoffRecoveryHint(qitemId: string, hostId: string | undefined): string {
|
||||
const remote = remoteHostOf(hostId);
|
||||
return `Before any retry, read source row ${qitemId} at the same endpoint and OPENRIG_HOME: `
|
||||
+ `rig queue show ${shellQuote(qitemId)} --full --json. Read its recorded outcome: a handed-off row names its successor`
|
||||
+ (remote ? ` on host ${shellQuote(remote)}` : "")
|
||||
+ ", or says why it can't. A row that still reads open doesn't show the handoff failed: it may still commit.";
|
||||
}
|
||||
|
||||
/** A handoff whose answer was lost (no answer in time, a lost connection, or an unreadable response) carries the hint in
|
||||
* its error. A pre-header disconnect can follow a committed handoff, as it can a create, so the shared renderer must not
|
||||
* call it undelivered. */
|
||||
function withHandoffRecovery(error: unknown, qitemId: string, hostId: string | undefined): unknown {
|
||||
if (!(error instanceof DaemonConnectionError || error instanceof DaemonResponseError)) return error;
|
||||
if (error instanceof DaemonConnectionError) error.writeOutcome = "unknown";
|
||||
error.message += ` ${handoffRecoveryHint(qitemId, hostId)}`;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** The selected daemon's forward to another host lost the remote's answer: the write was sent and may still commit. */
|
||||
function isIndeterminateRemoteWrite(status: number, body: unknown): boolean {
|
||||
return status >= 400 && !!body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
|
||||
&& (body as { outcome?: unknown }).outcome === "indeterminate";
|
||||
}
|
||||
|
||||
/** A `--host` id that may name another daemon. Empty and `local` never do; one that
|
||||
* equals the selected daemon's own host id is settled by {@link settleReceiptHost}. */
|
||||
function remoteHostOf(hostId: string | undefined): string | undefined {
|
||||
@@ -720,6 +695,167 @@ async function printQueueItemResult(
|
||||
await printWriteResult(client, json, body, status, receipt && remoteHost ? { ...receipt, remoteHost } : receipt, remoteHost !== undefined);
|
||||
}
|
||||
|
||||
const UPDATE_READ_BACK_FIELDS = [
|
||||
"state", "closureReason", "closureTarget", "closureRequiredAt", "resolution", "blockedOn", "summary", "evidenceRef",
|
||||
] as const;
|
||||
|
||||
interface QueueTransitionReadBack {
|
||||
transitionId: number;
|
||||
transitionNote: string | null;
|
||||
actorSession: string;
|
||||
}
|
||||
|
||||
function queueUpdateFieldMatches(field: (typeof UPDATE_READ_BACK_FIELDS)[number], requested: unknown, actual: unknown): boolean {
|
||||
if (field === "closureRequiredAt" && typeof requested === "string" && typeof actual === "string") {
|
||||
const requestedTime = Date.parse(requested);
|
||||
const actualTime = Date.parse(actual);
|
||||
return Number.isFinite(requestedTime) && Number.isFinite(actualTime) && requestedTime === actualTime;
|
||||
}
|
||||
if (field === "resolution" && typeof requested === "string" && typeof actual === "string") {
|
||||
return actual === requested || actual.endsWith(`\n${requested}`);
|
||||
}
|
||||
return Object.is(actual, requested);
|
||||
}
|
||||
|
||||
/** Whether a recorded note is this update's note as the daemon records it (QueueRepository.update): as written, or with
|
||||
* only what the daemon adds for this request, the reopen prefix, the summary suffix, or a revised deadline's receipt
|
||||
* naming the new time and the accepting actor. Another writer's note that merely starts the same way isn't. */
|
||||
function noteRecordedAs(actual: string, note: string, requested: Record<string, unknown>, actor: string, deadline: unknown): boolean {
|
||||
const bases = [note];
|
||||
if (requested.reopen === true) bases.push(`reopen acknowledged: ${note}`);
|
||||
if (typeof requested.summary === "string" && requested.summary.trim() && !note.includes("(summary updated)")) {
|
||||
bases.push(`${note} (summary updated)`);
|
||||
}
|
||||
if (requested.closureRequiredAt === undefined) return bases.includes(actual);
|
||||
// The receipt's earlier time isn't known here: it's one line between the fixed parts.
|
||||
const tail = ` -> ${String(deadline)}; accepted by ${actor}`;
|
||||
return bases.some((base) => {
|
||||
const head = `${base}\nclosure deadline re-estimated: `;
|
||||
if (!actual.startsWith(head) || !actual.endsWith(tail) || actual.length <= head.length + tail.length) return false;
|
||||
return !actual.slice(head.length, actual.length - tail.length).includes("\n");
|
||||
});
|
||||
}
|
||||
|
||||
/** Who the daemon records as this write's actor: the sender header as sent, else the actor the body declares. Empty when
|
||||
* neither is known. A daemon drops a sender's host suffix only when it's that daemon's own id, so the bare form is
|
||||
* this write's only where the selected daemon answers with that id; nothing else (an absent --host included) shows it. */
|
||||
async function updateActors(client: Pick<DaemonClient, "senderIdentity" | "get">, bodyActor: unknown): Promise<string[]> {
|
||||
const { session } = await client.senderIdentity();
|
||||
if (session) {
|
||||
const parts = session.split("@");
|
||||
if (parts.length !== 3) return [session];
|
||||
let daemonId: unknown;
|
||||
try {
|
||||
daemonId = (await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 })).data?.selfHostId;
|
||||
} catch { /* Unanswered: the suffix stays unproven, so only the full actor counts. */ }
|
||||
return daemonId === parts[2] ? [session, `${parts[0]}@${parts[1]}`] : [session];
|
||||
}
|
||||
return typeof bodyActor === "string" && bodyActor.trim() ? [bodyActor.trim()] : [];
|
||||
}
|
||||
|
||||
async function readQueueTransitionCursor(
|
||||
client: Pick<DaemonClient, "get">,
|
||||
qitemId: string,
|
||||
options: { headers?: Record<string, string>; timeoutMs?: number },
|
||||
): Promise<{ ok: true; latestTransitionId: number } | { ok: false; detail: string }> {
|
||||
let response: DaemonResponse<unknown>;
|
||||
try {
|
||||
response = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, options);
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `canonical transition preflight did not answer: ${(error as Error).message}` };
|
||||
}
|
||||
if (response.status !== 200 || !Array.isArray(response.data)) {
|
||||
return { ok: false, detail: `canonical transition preflight returned HTTP ${response.status} without a transition list` };
|
||||
}
|
||||
const transitionIds = response.data.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object") return [];
|
||||
const transitionId = (entry as { transitionId?: unknown }).transitionId;
|
||||
return typeof transitionId === "number" && Number.isSafeInteger(transitionId) ? [transitionId] : [];
|
||||
});
|
||||
if (transitionIds.length !== response.data.length) {
|
||||
return { ok: false, detail: "canonical transition preflight returned a malformed transition list" };
|
||||
}
|
||||
return { ok: true, latestTransitionId: Math.max(0, ...transitionIds) };
|
||||
}
|
||||
|
||||
async function readBackQueueUpdate(
|
||||
client: Pick<DaemonClient, "get">,
|
||||
qitemId: string,
|
||||
requested: Record<string, unknown>,
|
||||
options: { headers?: Record<string, string>; timeoutMs?: number },
|
||||
transitionFloor?: number,
|
||||
/** Why the transition cursor wasn't taken before the write, when it wasn't. */
|
||||
floorUnknown?: string,
|
||||
/** Who the daemon records as this write's actor (updateActors). */
|
||||
actors: string[] = [],
|
||||
): Promise<{ ok: true; row: Record<string, unknown> } | { ok: false; detail: string }> {
|
||||
let response: DaemonResponse<Record<string, unknown>>;
|
||||
try {
|
||||
response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `canonical read-back did not answer: ${(error as Error).message}` };
|
||||
}
|
||||
if (response.status !== 200 || !response.data || response.data.qitemId !== qitemId) {
|
||||
return { ok: false, detail: `canonical read-back returned HTTP ${response.status} without row ${qitemId}` };
|
||||
}
|
||||
const mismatches = UPDATE_READ_BACK_FIELDS.flatMap((field) => {
|
||||
if (requested[field] === undefined) return [];
|
||||
return queueUpdateFieldMatches(field, requested[field], response.data[field])
|
||||
? []
|
||||
: [`${field}: requested ${JSON.stringify(requested[field])}, read ${JSON.stringify(response.data[field])}`];
|
||||
});
|
||||
if (mismatches.length > 0) {
|
||||
return { ok: false, detail: `canonical read-back mismatch (${mismatches.join("; ")})` };
|
||||
}
|
||||
|
||||
const requestedNote = typeof requested.transitionNote === "string" && requested.transitionNote.trim().length > 0
|
||||
? requested.transitionNote
|
||||
: null;
|
||||
if (requestedNote !== null) {
|
||||
// Without the cursor, a transition carrying the note can't be shown to be this write's: never accept an older one.
|
||||
if (transitionFloor === undefined) {
|
||||
return { ok: false, detail: `the requested note can't be shown to be new, because the transition read before the write failed (${floorUnknown ?? "no cursor"})` };
|
||||
}
|
||||
if (actors.length === 0) {
|
||||
return { ok: false, detail: "the requested note can't be tied to this write, because the actor it was sent as isn't known" };
|
||||
}
|
||||
let transitions: DaemonResponse<unknown>;
|
||||
try {
|
||||
transitions = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, options);
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `canonical transition read-back did not answer: ${(error as Error).message}` };
|
||||
}
|
||||
if (transitions.status !== 200 || !Array.isArray(transitions.data)) {
|
||||
return { ok: false, detail: `canonical transition read-back returned HTTP ${transitions.status} without a transition list` };
|
||||
}
|
||||
const matching = transitions.data.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") return false;
|
||||
const transition = entry as Partial<QueueTransitionReadBack>;
|
||||
return typeof transition.transitionId === "number"
|
||||
&& transition.transitionId > transitionFloor
|
||||
&& typeof transition.transitionNote === "string"
|
||||
&& typeof transition.actorSession === "string"
|
||||
&& actors.includes(transition.actorSession)
|
||||
&& noteRecordedAs(transition.transitionNote, requestedNote, requested, transition.actorSession, response.data.closureRequiredAt);
|
||||
});
|
||||
if (!matching) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `no newer canonical transition carried requested note ${JSON.stringify(requestedNote)} from ${actors.map((actor) => JSON.stringify(actor)).join(" or ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, row: response.data };
|
||||
}
|
||||
|
||||
function emitQueueUpdateReadBackFailure(qitemId: string, detail: string, json: boolean): void {
|
||||
const message = `Queue update response was not accepted as success: ${detail}. The write may have committed, but its requested state was not verified.`;
|
||||
const action = `Run rig queue show ${shellQuote(qitemId)} --full --json against the same daemon before retrying.`;
|
||||
if (json) console.error(JSON.stringify({ error: "queue_update_readback_failed", message, action }));
|
||||
else console.error(`${message}\n${action}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// OPR.0.4.3.03 — `rig queue show` body preview.
|
||||
//
|
||||
// Default `show` renders a BOUNDED body preview instead of dumping the whole
|
||||
@@ -900,21 +1036,21 @@ function emitBodyResolveError(err: Error & { fact?: string; consequence?: string
|
||||
}
|
||||
|
||||
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
|
||||
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const session = explicit ?? resolveSenderSession();
|
||||
if (session) return session;
|
||||
|
||||
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set`);
|
||||
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set and reconciled tmux pane metadata does not resolve a session`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from OPENRIG_SESSION_NAME),
|
||||
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from managed identity),
|
||||
* where the named flag is deprecated and ignored: the error names what actually sets the actor, never the flag. */
|
||||
function requireSeatSession(ignoredFlag: string): string | undefined {
|
||||
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const session = resolveSenderSession();
|
||||
if (session) return session;
|
||||
|
||||
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set ` +
|
||||
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as, and reconciled tmux pane metadata does not resolve one. Run this from a managed seat, or set ` +
|
||||
`OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --${ignoredFlag} is ignored: the ` +
|
||||
"actor is the seat's own identity.");
|
||||
process.exitCode = 1;
|
||||
@@ -938,8 +1074,8 @@ const UPDATE_STATE_WRITE_FLAGS = [
|
||||
*/
|
||||
function resolveUpdateActor(source: string | undefined, opts: Record<string, unknown>): { actorSession?: string } | false {
|
||||
const refuse = (message: string): false => { console.error(message); process.exitCode = 1; return false; };
|
||||
// Trimmed like the DaemonClient's header (senderIdentityHeaders): a blank env is no seat, so the two never disagree.
|
||||
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || undefined;
|
||||
// The same resolver the DaemonClient header uses, so the preflight and transport can never disagree.
|
||||
const seat = resolveSenderSession();
|
||||
if (source !== undefined) {
|
||||
const trimmed = source.trim();
|
||||
if (parseSessionName(trimmed).kind !== "external") {
|
||||
@@ -959,10 +1095,10 @@ function resolveUpdateActor(source: string | undefined, opts: Record<string, unk
|
||||
if (seat) return {};
|
||||
const noteOnly = opts.note !== undefined && UPDATE_STATE_WRITE_FLAGS.every(([key]) => opts[key] === undefined);
|
||||
return refuse(noteOnly
|
||||
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set, so " +
|
||||
"there's no seat to act as: run this from a managed seat, or add --source <name>@external to record the note as " +
|
||||
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set and reconciled " +
|
||||
"tmux pane metadata does not resolve a seat: run this from a managed seat, or add --source <name>@external to record the note as " +
|
||||
"that external routine (claimed, never a seat). --actor is ignored: the actor is the seat's own identity."
|
||||
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set " +
|
||||
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as, and reconciled tmux pane metadata does not resolve one. Run this from a managed seat, or set " +
|
||||
"OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --actor is ignored: the actor is " +
|
||||
"the seat's own identity. Changing a row needs a seat, and notes need a seat; an external routine can pass " +
|
||||
"--source <name>@external for a note only. A person answering an ask addressed to them (a host pairing approval, " +
|
||||
@@ -1192,7 +1328,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
}
|
||||
// A managed seat keeps transport-derived identity; an external caller can
|
||||
// name the existing claimed:v1 body actor without forging a transport header.
|
||||
const managedSource = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const managedSource = resolveSenderSession();
|
||||
const source = resolveCurrentSession(managedSource ?? opts.source, "source");
|
||||
if (!source) return;
|
||||
const deps = getDeps();
|
||||
@@ -1494,7 +1630,7 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
const requested = {
|
||||
state: opts.state,
|
||||
reopen: opts.reopen,
|
||||
closureReason: opts.closureReason,
|
||||
@@ -1508,7 +1644,29 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
...actor,
|
||||
}, target.options);
|
||||
};
|
||||
let transitionFloor: number | undefined;
|
||||
let floorUnknown: string | undefined;
|
||||
if (typeof requested.transitionNote === "string" && requested.transitionNote.trim().length > 0) {
|
||||
// The cursor proves the note is new afterwards. Without it the write still goes; the receipt says what's unproven.
|
||||
const cursor = await readQueueTransitionCursor(target.client, qitemId, target.options);
|
||||
if (cursor.ok) transitionFloor = cursor.latestTransitionId;
|
||||
else floorUnknown = cursor.detail;
|
||||
}
|
||||
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, requested, target.options);
|
||||
if (res.status < 400) {
|
||||
const actors = await updateActors(target.client, requested.actorSession);
|
||||
const verified = await readBackQueueUpdate(target.client, qitemId, requested, target.options, transitionFloor, floorUnknown, actors);
|
||||
if (!verified.ok) {
|
||||
emitQueueUpdateReadBackFailure(qitemId, verified.detail, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
// The canonical row's fields win; the write's own advisories (a park with no wake) come only with its response.
|
||||
const advisories = (res.data as { advisories?: unknown } | null)?.advisories;
|
||||
const row = Array.isArray(advisories) ? { ...verified.row, advisories } : verified.row;
|
||||
await printQueueItemResult(target.client, qitemId, opts.json ?? false, row, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
|
||||
return;
|
||||
}
|
||||
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
|
||||
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
|
||||
});
|
||||
@@ -1713,13 +1871,12 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => { throw withHandoffRecovery(error, qitemId, hostResolved.hostId); });
|
||||
}, crossHostWriteOptions(hostResolved.hostId));
|
||||
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
|
||||
if (hint) console.error(hint);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
|
||||
verb: "handoff", nudgeSuppressed: opts.nudge === false, remoteHost: remoteHostOf(hostResolved.hostId),
|
||||
}));
|
||||
if (isIndeterminateRemoteWrite(res.status, res.data)) console.error(handoffRecoveryHint(qitemId, hostResolved.hostId));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1816,13 +1973,12 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => { throw withHandoffRecovery(error, qitemId, hostResolved.hostId); });
|
||||
}, crossHostWriteOptions(hostResolved.hostId));
|
||||
const hint = hostSubstitutionHint(res.status, res.data, opts.to, opts.host, hostResolved);
|
||||
if (hint) console.error(hint);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, {
|
||||
verb: "handoff-and-complete", nudgeSuppressed: opts.nudge === false, remoteHost: remoteHostOf(hostResolved.hostId),
|
||||
}));
|
||||
if (isIndeterminateRemoteWrite(res.status, res.data)) console.error(handoffRecoveryHint(qitemId, hostResolved.hostId));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -516,15 +516,8 @@ Examples:
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List workflow instances with optional exact filters")
|
||||
.description("List workflow instances; optionally filter by status")
|
||||
.option("--status <s>", "Filter by status (active | waiting | completed | failed | aborted)")
|
||||
.option("--project <id>", "Match the instance project")
|
||||
.option("--mission <id>", "Match the instance mission")
|
||||
.option("--slice <id>", "Match the instance slice")
|
||||
.option("--rig <name>", "Match the bound rig")
|
||||
.option("--name <workflow>", "Match the workflow name")
|
||||
.option("--tag <tag>", "Match a scope tag; repeat to require all tags", (tag: string, previous: string[]) => [...previous, tag], [])
|
||||
.option("--limit <n>", "Maximum matching instances (1-10000)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -532,12 +525,10 @@ Examples:
|
||||
$ rig workflow list --status active --json
|
||||
$ rig workflow list --status waiting | jq -r '.instances[].instanceId'
|
||||
`)
|
||||
.action(async (opts: { status?: string; project?: string; mission?: string; slice?: string; rig?: string; name?: string; tag: string[]; limit?: string; json?: boolean }) => {
|
||||
.action(async (opts: { status?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
for (const field of ["project", "mission", "slice", "rig", "name", "limit"] as const) if (opts[field] !== undefined) params.set(field, opts[field]!);
|
||||
for (const tag of opts.tag) params.append("tag", tag);
|
||||
const qs = params.toString();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome } from "./openrig-compat.js";
|
||||
|
||||
/** This home's database, opened read-only without requiring the local daemon. better-sqlite3 loads only when this is
|
||||
* called, so importing this module costs a command nothing. Throws when there's no database. */
|
||||
export function openLocalDbReadonly(): Database.Database {
|
||||
const configured = new ConfigStore().resolveWithSource("db.path");
|
||||
let dbPath = configured.value as string;
|
||||
// An explicit configured DB wins. Otherwise retain the last launch's --db selection.
|
||||
if (configured.source === "default") {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(join(getOpenRigHome(), "daemon.json"), "utf8"));
|
||||
if (typeof state.db === "string" && state.db.length > 0) dbPath = state.db;
|
||||
} catch { /* No launch record: use the configured default, never search other homes. */ }
|
||||
}
|
||||
const Sqlite = createRequire(import.meta.url)("better-sqlite3") as typeof Database;
|
||||
return new Sqlite(dbPath, { readonly: true, fileMustExist: true, timeout: 100 });
|
||||
}
|
||||
@@ -1,24 +1,12 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome } from "./openrig-compat.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { validateHostRegistry } from "./host-registry.js";
|
||||
import { openLocalDbReadonly } from "./local-db.js";
|
||||
|
||||
/** Read the boot-minted identity without requiring the local daemon or minting a replacement. */
|
||||
export function readLocalOrigin(): string | undefined {
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
const configured = new ConfigStore().resolveWithSource("db.path");
|
||||
let dbPath = configured.value as string;
|
||||
// An explicit configured DB wins. Otherwise retain the last launch's --db selection.
|
||||
if (configured.source === "default") {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(join(getOpenRigHome(), "daemon.json"), "utf8"));
|
||||
if (typeof state.db === "string" && state.db.length > 0) dbPath = state.db;
|
||||
} catch { /* No launch record: use the configured default, never search other homes. */ }
|
||||
}
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 100 });
|
||||
db = openLocalDbReadonly();
|
||||
const row = db.prepare("SELECT host_id FROM self_host_identity WHERE singleton = 1").get() as { host_id?: unknown } | undefined;
|
||||
const id = row?.host_id;
|
||||
if (typeof id !== "string" || id === "localhost") return undefined;
|
||||
|
||||
@@ -651,35 +651,11 @@ function sharedInstructions(has: (tool: string) => boolean): string[] {
|
||||
const SHARED_INSTRUCTIONS = sharedInstructions(() => true);
|
||||
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
|
||||
|
||||
/** For a connection that relays a person's request to another assistant (an external caller, or the front door): which one
|
||||
* they mean is theirs to say when more than one fits, never inferred from the agent's own history. The listing steps need
|
||||
* rig_team, so a connection without it is told only to ask. Pickup is said only where it's true: another connected
|
||||
* assistant waits for its person; on the front door, a running team seat is woken, and one sent to anyway waits until it's
|
||||
* back. A member, which works items, isn't given it. */
|
||||
function askWhichAssistant(canList: boolean, frontDoor: boolean): string {
|
||||
if (!canList) {
|
||||
return "If more than one assistant could be the one your person means, ask which; don't pick from your own history or memory. If your person names one clearly, go ahead.";
|
||||
}
|
||||
return [
|
||||
"Before sending a request to another assistant, list the team's members.",
|
||||
"If more than one could be the one your person means, ask which, using their names from the list (like \"Sam's Claude\"); if the list shows only addresses, describe each in plain words from what the list says about it (like \"the Claude assistant\"), never reading the address out.",
|
||||
"Don't pick from your own history or memory.",
|
||||
"If the list shows the chosen one detached or stopped, or a connected assistant with no check-in yet or overdue, say so before sending and ask whether to send it anyway.",
|
||||
"If the list says the chosen one's person was removed from the team, don't send; tell your person and ask whether to send it to someone else.",
|
||||
...(frontDoor ? ["When you send to a running team seat, tell your person that seat will pick it up on its own."] : []),
|
||||
"When you send to another connected assistant, tell your person it will be picked up the next time that assistant's person opens it, or on a schedule they've set up.",
|
||||
...(frontDoor ? ["If you sent it anyway, tell your person it waits until that one is back."] : []),
|
||||
"If your person names one clearly, or says to send it anyway to one that isn't connected, go ahead without asking again.",
|
||||
"If your person still asks to send to one whose person was removed, tell them it can't be delivered: requests to a removed person's assistant are closed unanswered.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** rig mcp serve --front-door: the seat is a person's agent in their own app, the front door to their team, not a member
|
||||
* that works its inbox. The team's questions to it are the person's to answer. */
|
||||
export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v3";
|
||||
export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v2";
|
||||
export const FRONT_DOOR_WORKFLOW = [
|
||||
`Front door (${FRONT_DOOR_WORKFLOW_VERSION}). This seat is a person's agent in their own app: you hand the team their work and bring back what it produced, and you aren't one of its members.`,
|
||||
askWhichAssistant(true, true),
|
||||
"Items addressed to your seat (rig_queue_list with owned set) are usually the team's questions about work you handed it. They're the person's to answer, not yours: show each to the person with the team's draft, and record only what the person tells you.",
|
||||
"To record an answer, claim the item (rig_queue_claim), then close it done with closure reason no-follow-on and exactly what the person typed in resolution, with no label, quotation marks or words of your own (rig_queue_update). If the person isn't there, leave the item open and tell them it's waiting. Never choose an answer for them, and don't claim an item you can't close with their words.",
|
||||
"Nothing wakes this conversation when the team finishes: check your work and your items when the person asks or comes back.",
|
||||
@@ -717,11 +693,8 @@ function externalInboxInstructions(tools: ReadonlySet<string>): string {
|
||||
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
|
||||
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
|
||||
const tools = externalTools(access.external);
|
||||
const workflow = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? externalInboxInstructions(tools)
|
||||
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? externalInboxInstructions(tools)
|
||||
: tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
// Only a grant with team/list may reach another connected assistant's seat, and it lists the team with rig_team.
|
||||
const instructions = tools.has("rig_queue_create")
|
||||
? `${workflow}\n${askWhichAssistant(tools.has("rig_team"), false)}` : workflow;
|
||||
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,90 @@
|
||||
import { readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type Database from "better-sqlite3";
|
||||
import { openLocalDbReadonly } from "./local-db.js";
|
||||
|
||||
export type TmuxIdentityReader = (pane: string, option: string) => string | undefined;
|
||||
|
||||
/** A busy or wedged tmux server must not hold every queue request: past this, the pane's seat is unresolved. */
|
||||
export const TMUX_IDENTITY_TIMEOUT_MS = 1_000;
|
||||
|
||||
const readTmuxIdentity: TmuxIdentityReader = (pane, option) => {
|
||||
try {
|
||||
const value = execFileSync("tmux", ["show-option", "-v", "-t", pane, option], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: TMUX_IDENTITY_TIMEOUT_MS,
|
||||
}).trim();
|
||||
return value || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the daemon still binds a node to a tmux session. Claim records the binding beside the session's metadata;
|
||||
* unclaim deletes it in its own transaction even when clearing the metadata fails. */
|
||||
export type BindingCheck = (nodeId: string, tmuxSession: string) => boolean;
|
||||
|
||||
export function bindingIsCurrent(db: Database.Database, nodeId: string, tmuxSession: string): boolean {
|
||||
return db.prepare("SELECT 1 FROM bindings WHERE node_id = ? AND tmux_session = ?").get(nodeId, tmuxSession) !== undefined;
|
||||
}
|
||||
|
||||
/** The binding as this home's database records it; no database, or no answer, isn't evidence of a binding. */
|
||||
const localBindingIsCurrent: BindingCheck = (nodeId, tmuxSession) => {
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
db = openLocalDbReadonly();
|
||||
return bindingIsCurrent(db, nodeId, tmuxSession);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
};
|
||||
|
||||
// The pane's seat, read once per run (runProgram resets it), so a command's preflight and every request it sends share one
|
||||
// value and one tmux read. Keyed by what produced it, so a different pane or reader reads again.
|
||||
let paneSeat: { pane: string; tmuxReader: TmuxIdentityReader; isBound: BindingCheck; session: string | undefined } | undefined;
|
||||
|
||||
/** Forget the pane seat read for the previous run. */
|
||||
export function resetSenderSession(): void {
|
||||
paneSeat = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seat identity a CLI dispatch is attributed to — the SAME value the
|
||||
* DaemonClient stamps as `X-OpenRig-Session` (the P18 chokepoint), from which
|
||||
* the daemon DERIVES the actor / rendered `From:`. `--from` is deprecated and
|
||||
* ignored: origin is the seat env, never a caller-supplied body claim. Returns
|
||||
* `undefined` when the seat is unresolvable — the caller then delivers-and-labels
|
||||
* with `SENDER_FALLBACK` (never a refusal, never a forged actor).
|
||||
* the daemon DERIVES the actor / rendered `From:`. The process seat env wins;
|
||||
* a reconciled managed pane's `@rigged_session_name` repairs a live process
|
||||
* that predates its env binding, but only while the daemon still binds that
|
||||
* session's node to it: metadata left behind on a released session isn't a
|
||||
* current seat. Caller-supplied body claims never become a seat. Returns
|
||||
* `undefined` when neither managed source resolves.
|
||||
*/
|
||||
export function resolveSenderSession(): string | undefined {
|
||||
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
export function resolveSenderSession(
|
||||
tmuxReader: TmuxIdentityReader = readTmuxIdentity,
|
||||
isBound: BindingCheck = localBindingIsCurrent,
|
||||
): string | undefined {
|
||||
const env = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
|
||||
if (env) return env;
|
||||
const pane = process.env.TMUX_PANE?.trim();
|
||||
if (!pane) return undefined;
|
||||
if (paneSeat?.pane === pane && paneSeat.tmuxReader === tmuxReader && paneSeat.isBound === isBound) return paneSeat.session;
|
||||
const session = boundPaneSeat(pane, tmuxReader, isBound);
|
||||
paneSeat = { pane, tmuxReader, isBound, session };
|
||||
return session;
|
||||
}
|
||||
|
||||
function boundPaneSeat(pane: string, tmuxReader: TmuxIdentityReader, isBound: BindingCheck): string | undefined {
|
||||
const session = tmuxReader(pane, "@rigged_session_name")?.trim();
|
||||
if (!session) return undefined;
|
||||
const nodeId = tmuxReader(pane, "@rigged_node_id")?.trim();
|
||||
return nodeId && isBound(nodeId, session) ? session : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session-less fall-open marker for an unattributable CLI dispatch. P18 (deletion atom) REVERSES A1's
|
||||
* seat-boundary refusal: an env-less `rig send`/`broadcast` now DELIVERS carrying this HONEST label rather
|
||||
* seat-boundary refusal: an unresolved `rig send`/`broadcast` now DELIVERS carrying this HONEST label rather
|
||||
* than refusing, because the daemon half delivers-and-labels the header-absent write (no downstream 401) —
|
||||
* unverified is labelled unknown, never laundered to verified.
|
||||
*
|
||||
|
||||
@@ -513,6 +513,17 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
|
||||
|
||||
describe("script-safety integration (the compiled front door)", () => {
|
||||
const cliEntry = join(__dirname, "..", "dist", "index.js");
|
||||
it.skipIf(!existsSync(cliEntry))("an unresolved queue actor stays nonzero through a pipe with pipefail", () => {
|
||||
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-o", "pipefail", "-c",
|
||||
'env -u OPENRIG_SESSION_NAME -u RIGGED_SESSION_NAME -u TMUX_PANE "$NODE" "$CLI" queue update qitem-pipe-proof --state in-progress --json 2>&1 | cat'], {
|
||||
timeout: 5000,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, NODE: process.execPath, CLI: cliEntry },
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("OPENRIG_SESSION_NAME is not set");
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(cliEntry))("`echo x | rig` returns usage fast — no hang (exit code preserves today's clean-help baseline)", () => {
|
||||
const started = Date.now();
|
||||
const result = spawnSync(process.execPath, [cliEntry], {
|
||||
|
||||
@@ -19,16 +19,6 @@ function mockClient(overrides?: {
|
||||
return client;
|
||||
}
|
||||
|
||||
// What a connection that relays a person's request is told when more than one assistant could be the one they mean. With
|
||||
// rig_team it lists the team first; pickup is said only where it's true for the path.
|
||||
const ASK_LISTED = "Before sending a request to another assistant, list the team's members. If more than one could be the one your person means, ask which, using their names from the list (like \"Sam's Claude\"); if the list shows only addresses, describe each in plain words from what the list says about it (like \"the Claude assistant\"), never reading the address out. Don't pick from your own history or memory. If the list shows the chosen one detached or stopped, or a connected assistant with no check-in yet or overdue, say so before sending and ask whether to send it anyway. If the list says the chosen one's person was removed from the team, don't send; tell your person and ask whether to send it to someone else.";
|
||||
const ASK_RUNNING_PICKUP = "When you send to a running team seat, tell your person that seat will pick it up on its own.";
|
||||
const ASK_CONNECTED_PICKUP = "When you send to another connected assistant, tell your person it will be picked up the next time that assistant's person opens it, or on a schedule they've set up.";
|
||||
const ASK_SENT_ANYWAY = "If you sent it anyway, tell your person it waits until that one is back.";
|
||||
const ASK_UNLISTED = "If more than one assistant could be the one your person means, ask which; don't pick from your own history or memory. If your person names one clearly, go ahead.";
|
||||
const ASK_GO_AHEAD = "If your person names one clearly, or says to send it anyway to one that isn't connected, go ahead without asking again.";
|
||||
const ASK_REMOVED_FACT = "If your person still asks to send to one whose person was removed, tell them it can't be delivered: requests to a removed person's assistant are closed unanswered.";
|
||||
|
||||
describe("MCP Server", () => {
|
||||
let mcpClient: Client;
|
||||
let daemonClient: DaemonClient;
|
||||
@@ -405,10 +395,6 @@ describe("MCP Server", () => {
|
||||
expect(instructions).not.toContain(MEMBER_WORKFLOW);
|
||||
expect(instructions).not.toContain("without asking whether you may");
|
||||
for (const part of ["the person's to answer", "record only what the person tells you", "Never choose an answer for them", "exactly what the person typed in resolution, with no label"]) expect(FRONT_DOOR_WORKFLOW).toContain(part);
|
||||
// It hands the team the person's work, so which assistant they mean is theirs to say when several fit. Its list shows
|
||||
// team seats and connected assistants: only a running team seat picks a request up on its own.
|
||||
expect(FRONT_DOOR_WORKFLOW).toContain(`${ASK_LISTED} ${ASK_RUNNING_PICKUP} ${ASK_CONNECTED_PICKUP} ${ASK_SENT_ANYWAY} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
|
||||
expect(FRONT_DOOR_WORKFLOW).not.toContain("<name>");
|
||||
// An agent told to say the words are the person's wrapped them as `The person's answer: "…"`, so the stored answer wasn't what they typed.
|
||||
expect(FRONT_DOOR_WORKFLOW).not.toContain("saying they're the person's");
|
||||
const front = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
|
||||
@@ -891,33 +877,11 @@ describe("MCP Server", () => {
|
||||
expect(instructions).not.toContain(MEMBER_WORKFLOW_VERSION);
|
||||
expect(instructions).not.toContain("basically authorized");
|
||||
expect(instructions).toContain("You may omit destination");
|
||||
// Asked to "send my Claude assistant one request" on a team with four Claude seats, a client picked one from its own history.
|
||||
// Without rig_team it can't list the team, and its destinations aren't other connected assistants: it's told only to ask.
|
||||
expect(instructions).toContain(ASK_UNLISTED);
|
||||
expect(instructions).not.toContain("list the team's members");
|
||||
expect(instructions).not.toContain("removed from the team");
|
||||
expect(instructions).not.toContain("closed unanswered");
|
||||
expect(instructions).not.toMatch(/picked up|pick it up/);
|
||||
expect(instructions).not.toMatch(/rig_whoami|rig_team|rig_queue_handoff|rig_queue_decide/);
|
||||
await external.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("an external request grant with team/list lists the team before asking which assistant, and says when another connected one picks up", async () => {
|
||||
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
|
||||
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects: { queue: ["create", "show", "list"], team: ["list"] } } } });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const external = new Client({ name: "external-client", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
await external.connect(clientTransport);
|
||||
const instructions = external.getInstructions() ?? "";
|
||||
expect(instructions).toContain(`${ASK_LISTED} ${ASK_CONNECTED_PICKUP} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
|
||||
expect(instructions).not.toContain(ASK_RUNNING_PICKUP);
|
||||
expect(instructions).not.toContain(ASK_SENT_ANYWAY);
|
||||
await external.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a full inbox grant", { queue: ["create", "show", "list", "claim", "update"], team: ["list"] }],
|
||||
["a narrow inbox grant", { queue: ["claim", "update"] }],
|
||||
@@ -935,10 +899,6 @@ describe("MCP Server", () => {
|
||||
expect(named.filter((tool) => !offered.has(tool))).toEqual([]);
|
||||
expect(instructions).not.toMatch(/rig_whoami|rig_queue_handoff|rig_queue_decide/);
|
||||
expect(instructions.includes("rig_team")).toBe(offered.has("rig_team"));
|
||||
// Only a grant that can hand the team work is told to ask which assistant the person means; with team/list it lists the
|
||||
// team first and may reach another connected assistant, so it's told when that one picks the request up.
|
||||
expect(instructions.includes("ask which")).toBe(offered.has("rig_queue_create"));
|
||||
if (offered.has("rig_queue_create")) expect(instructions).toContain(`${ASK_LISTED} ${ASK_CONNECTED_PICKUP} ${ASK_GO_AHEAD} ${ASK_REMOVED_FACT}`);
|
||||
await external.close();
|
||||
await server.close();
|
||||
});
|
||||
@@ -951,8 +911,6 @@ describe("MCP Server", () => {
|
||||
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
|
||||
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
|
||||
]) expect(instructions).toContain(paragraph);
|
||||
// A member works items rather than relaying a person's request, so it isn't told to ask which assistant they mean.
|
||||
expect(instructions).not.toContain("ask which");
|
||||
});
|
||||
|
||||
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
|
||||
|
||||
@@ -6,8 +6,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { QueueDeps } from "../src/commands/queue.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "../src/client.js";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
import { DaemonTimeoutError } from "../src/client.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
@@ -45,15 +44,13 @@ function makeDeps(replies: Reply[]): { deps: QueueDeps; requests: Request[] } {
|
||||
describe("cross-host queue writes wait for the daemon's answer", () => {
|
||||
let home: string;
|
||||
let stderr: string[];
|
||||
let stdout: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "queue-xhost-wait-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "lead@rig-a");
|
||||
stderr = [];
|
||||
stdout = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { stdout.push(a.join(" ")); });
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { stderr.push(a.join(" ")); });
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { stderr.push(String(chunk)); return true; });
|
||||
process.exitCode = undefined;
|
||||
@@ -95,86 +92,17 @@ describe("cross-host queue writes wait for the daemon's answer", () => {
|
||||
}
|
||||
|
||||
it("a create that gets no answer in time reads as unknown, never as a failure, with the ID to reconcile by", async () => {
|
||||
await expect(run(["create", "--destination", "dev@rig-b@vps-b", "--body", "b", "--summary", "s"],
|
||||
[new DaemonTimeoutError("Request to http://localhost:7433/api/queue/create timed out after 15000ms")])).rejects.toThrow();
|
||||
let thrown: unknown;
|
||||
await run(["create", "--destination", "dev@rig-b@vps-b", "--body", "b", "--summary", "s"],
|
||||
[new DaemonTimeoutError("Request to http://localhost:7433/api/queue/create timed out after 15000ms")]).catch((e: unknown) => { thrown = e; });
|
||||
const all = stderr.join("\n");
|
||||
expect(all).toContain("queue request got no answer in time (routing to host 'vps-b')");
|
||||
expect(all).not.toContain("queue transport failure");
|
||||
expect(all).toContain("The write outcome is INDETERMINATE");
|
||||
expect(all).toMatch(/repeat the same create with --id 'qitem-\d{14}-[0-9a-f]{16}'/);
|
||||
// The final timeout error carries the same-ID instruction itself, not only the request line printed before the write.
|
||||
const id = /Queue create request ID: (qitem-\d{14}-[0-9a-f]{16})/.exec(all)?.[1];
|
||||
expect(id).toBeDefined();
|
||||
expect(thrown).toBeInstanceOf(DaemonTimeoutError);
|
||||
expect((thrown as Error).message).toContain(`repeat the same create with --id '${id}' and unchanged source, destination, body and options`);
|
||||
});
|
||||
|
||||
// A handoff's lost answer leaves the source row, on the daemon the command used, as the thing to read: its successor is
|
||||
// named there once recorded. The error never names a successor id, says the handoff failed, or asks to repeat it.
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
for (const [where, to, successor] of [
|
||||
["to another host", "dev@rig-b@vps-b", "Read its recorded outcome: a handed-off row names its successor on host 'vps-b', or says why it can't."],
|
||||
["on this daemon", "dev@rig-b", "Read its recorded outcome: a handed-off row names its successor, or says why it can't."],
|
||||
] as const) {
|
||||
it(`a ${verb} ${where} that gets no answer in time names the source row to read before any retry`, async () => {
|
||||
let thrown: unknown;
|
||||
await run([verb, "qitem-src-1", "--to", to, "--summary", "s"],
|
||||
[new DaemonTimeoutError(`Request to http://localhost:7433/api/queue/qitem-src-1/${verb} timed out after 15000ms`)])
|
||||
.catch((e: unknown) => { thrown = e; });
|
||||
expect(thrown).toBeInstanceOf(DaemonTimeoutError);
|
||||
const message = (thrown as Error).message;
|
||||
expect(message).toContain("Before any retry, read source row qitem-src-1 at the same endpoint and OPENRIG_HOME: rig queue show 'qitem-src-1' --full --json.");
|
||||
expect(message).toContain(successor);
|
||||
expect(message).toContain("doesn't show the handoff failed");
|
||||
expect(message).not.toMatch(/--id|repeat the/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered as a person sees it, through the shared program renderer: a lost connection or an unreadable response after
|
||||
// a handoff was sent is an unknown outcome, never "not delivered", and keeps the source row to read.
|
||||
async function runRendered(argv: string[], replies: Reply[], json: boolean): Promise<{ text: string; code: number }> {
|
||||
const { deps } = makeDeps(replies);
|
||||
const lines: string[] = [];
|
||||
const code = await runProgram(createProgram({ queueDeps: deps }), ["node", "rig", "queue", ...argv, ...(json ? ["--json"] : [])],
|
||||
{ out: (l) => lines.push(l), err: (l) => lines.push(l), exit: () => {} });
|
||||
return { text: [...stderr, ...lines].join("\n"), code };
|
||||
}
|
||||
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
for (const [kind, error] of [
|
||||
["a lost connection", () => new DaemonConnectionError("Cannot connect to the daemon at http://localhost:7433: fetch failed", "ECONNRESET")],
|
||||
["an unreadable response", () => new DaemonResponseError(200, "")],
|
||||
] as const) {
|
||||
for (const json of [false, true]) {
|
||||
it(`a ${verb} that meets ${kind} reads as an unknown outcome, never undelivered (${json ? "--json" : "human"})`, async () => {
|
||||
const { text, code } = await runRendered([verb, "qitem-src-1", "--to", "dev@rig-b", "--summary", "s"], [error()], json);
|
||||
expect(code).toBe(1);
|
||||
expect(text).toContain("outcome is UNKNOWN");
|
||||
expect(text).not.toMatch(/not delivered/i);
|
||||
expect(text).toContain("Before any retry, read source row qitem-src-1");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The selected daemon's forward can answer with a structured reply instead of throwing. An explicit indeterminate one
|
||||
// gets the same source-row hint, with the reply and its exit code unchanged; a refusal before any forward implies no
|
||||
// write that may still commit.
|
||||
const INDETERMINATE = { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "unreachable",
|
||||
outcome: "indeterminate", detail: "remote queue write timed out after 10000ms; the remote may still commit it" } };
|
||||
const REFUSED = { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "unsupported-transport",
|
||||
detail: "host 'vps-b' is SSH-declared" } };
|
||||
for (const verb of ["handoff", "handoff-and-complete"] as const) {
|
||||
it(`a ${verb} whose forward answers indeterminate names the source row to read, keeping the reply and exit code`, async () => {
|
||||
await run([verb, "qitem-src-1", "--to", "dev@rig-b@vps-b", "--summary", "s"], [INDETERMINATE]);
|
||||
const all = stderr.join("\n");
|
||||
expect(all).toContain("Before any retry, read source row qitem-src-1 at the same endpoint and OPENRIG_HOME: rig queue show 'qitem-src-1' --full --json.");
|
||||
expect(all).toContain("a handed-off row names its successor on host 'vps-b', or says why it can't.");
|
||||
expect(stdout).toEqual([JSON.stringify(INDETERMINATE.data)]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it(`a ${verb} the daemon refused to forward says nothing may still commit`, async () => {
|
||||
await run([verb, "qitem-src-1", "--to", "dev@rig-b@vps-b", "--summary", "s"], [REFUSED]);
|
||||
expect(stderr.join("\n")).not.toMatch(/may still commit|INDETERMINATE|Before any retry/);
|
||||
expect(stdout).toEqual([JSON.stringify(REFUSED.data)]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -52,6 +52,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
let local: Awaited<ReturnType<typeof serve>>;
|
||||
let remote: Awaited<ReturnType<typeof serve>>;
|
||||
let remoteAnswer: Answer;
|
||||
let remoteRow: Record<string, unknown>;
|
||||
let home: string;
|
||||
let out: string[];
|
||||
let err: string[];
|
||||
@@ -79,11 +80,22 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
? { status: 200, data: { healthy: true, selfHostId: "vm-a" } }
|
||||
: { status: 500, data: { error: "the selected daemon must not be asked for the row" } });
|
||||
localPort = Number(new URL(local.url).port);
|
||||
remoteRow = { ...ROW };
|
||||
const remoteTransitions: Array<{ transitionId: number; state: string; transitionNote: string | null }> = [
|
||||
{ transitionId: 1, state: "pending", transitionNote: null },
|
||||
];
|
||||
remoteAnswer = (req) => {
|
||||
if (req.path === "/healthz") return { status: 200, data: { healthy: true, selfHostId: "devbox-id" } };
|
||||
if (req.path.endsWith("/transitions")) return { status: 200, data: [{ transitionId: 1, state: "pending" }] };
|
||||
if (req.path.endsWith("/transitions")) return { status: 200, data: remoteTransitions };
|
||||
if (req.path.startsWith("/api/queue/list")) return { status: 200, data: [ROW] };
|
||||
return { status: 200, data: ROW };
|
||||
if (req.method === "POST" && req.path.endsWith("/update")) {
|
||||
remoteRow = { ...remoteRow, ...(req.body as Record<string, unknown>) };
|
||||
const transitionNote = (req.body as { transitionNote?: unknown }).transitionNote;
|
||||
if (typeof transitionNote === "string") {
|
||||
remoteTransitions.push({ transitionId: remoteTransitions.length + 1, state: String(remoteRow.state), transitionNote });
|
||||
}
|
||||
}
|
||||
return { status: 200, data: remoteRow };
|
||||
};
|
||||
remote = await serve((req) => remoteAnswer(req));
|
||||
hosts = [
|
||||
@@ -121,7 +133,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
it("transitions --host reads the remote log", async () => {
|
||||
await run("transitions", "qitem-remote-1", "--host", "devbox", "--json", "--full");
|
||||
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/transitions"]);
|
||||
expect(JSON.parse(out.join("\n"))).toEqual([{ transitionId: 1, state: "pending" }]);
|
||||
expect(JSON.parse(out.join("\n"))).toEqual([{ transitionId: 1, state: "pending", transitionNote: null }]);
|
||||
});
|
||||
|
||||
it("list --host lists the remote rows, its filters kept and no rig derived from this host's seat", async () => {
|
||||
@@ -143,7 +155,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
|
||||
it("update --host writes on the remote daemon, the actor stamped with this host's id", async () => {
|
||||
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "done", "--closure-reason", "no-follow-on", "--note", "settled from vm-a", "--json");
|
||||
const [call] = remoteQueueCalls();
|
||||
const call = remoteQueueCalls().find((entry) => entry.method === "POST");
|
||||
expect(call).toMatchObject({ method: "POST", path: "/api/queue/qitem-remote-1/update" });
|
||||
expect(call!.headers["x-openrig-session"]).toBe("dev@fabrica@vm-a");
|
||||
expect(call!.headers.authorization).toBe("Bearer s3cret");
|
||||
@@ -208,7 +220,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
it("a self-id learned at the entry's current URL routes to that entry", async () => {
|
||||
bind({ observedUrl: remote.url });
|
||||
await run("update", "qitem-remote-1", "--host", "devbox-self-id", "--state", "done", "--json");
|
||||
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/update"]);
|
||||
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/update", "/api/queue/qitem-remote-1"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -263,7 +275,9 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
const { compactOutputEnabled } = await import("../src/queue-receipt.js");
|
||||
expect(compactOutputEnabled()).toBe(true);
|
||||
// The remote daemon's own id equals the alias: the receipt must still call the row remote.
|
||||
remoteAnswer = (req) => req.path === "/healthz" ? { status: 200, data: { selfHostId: "devbox" } } : { status: 200, data: ROW };
|
||||
remoteAnswer = (req) => req.path === "/healthz"
|
||||
? { status: 200, data: { selfHostId: "devbox" } }
|
||||
: { status: 200, data: { ...ROW, state: "in-progress" } };
|
||||
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "in-progress", "--json");
|
||||
const receipt = JSON.parse(out.join("\n"));
|
||||
expect(receipt.host).toBe("devbox");
|
||||
@@ -274,13 +288,15 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
local.close();
|
||||
local = await serve((req) => req.path === "/healthz"
|
||||
? { status: 200, data: { selfHostId: "vm-a" } }
|
||||
: { status: 200, data: req.path.startsWith("/api/queue/list") ? [] : ROW });
|
||||
: { status: 200, data: req.path.startsWith("/api/queue/list") ? [] : { ...ROW, state: "done" } });
|
||||
localPort = Number(new URL(local.url).port);
|
||||
await run("show", "qitem-remote-1", "--json");
|
||||
await run("list", "--json");
|
||||
await run("update", "qitem-remote-1", "--state", "done", "--json");
|
||||
expect(remote.seen).toEqual([]);
|
||||
expect(localQueueCalls().map((c) => c.path.split("?")[0])).toEqual(["/api/queue/qitem-remote-1", "/api/queue/list", "/api/queue/qitem-remote-1/update"]);
|
||||
expect(localQueueCalls().map((c) => c.path.split("?")[0])).toEqual([
|
||||
"/api/queue/qitem-remote-1", "/api/queue/list", "/api/queue/qitem-remote-1/update", "/api/queue/qitem-remote-1",
|
||||
]);
|
||||
// The local list still derives the rig from the seat, and no sender is stamped with a host id.
|
||||
expect(new URL(localQueueCalls()[1]!.path, "http://x").searchParams.get("rig")).toBe("fabrica");
|
||||
expect(localQueueCalls()[2]!.headers["x-openrig-session"]).toBe("dev@fabrica");
|
||||
|
||||
@@ -68,8 +68,11 @@ for (const json of [false, true]) {
|
||||
expect(body.hint).toBe(`Queue item "${id}" was not found on daemon "selected-host".\n`
|
||||
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(id)} --full --json\n`
|
||||
+ `Or name that host: rig queue show ${shellQuote(id)} --host <host-id> --full --json`);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
|
||||
// A note's update first reads the row's transitions (read-only), so its read-back can prove the note is new.
|
||||
const preflight = verb === "update" ? 1 : 0;
|
||||
expect(calls).toHaveLength(2 + preflight);
|
||||
if (preflight) expect(calls[0]!.path).toBe(`/api/queue/${id}/transitions`);
|
||||
expect(calls[1 + preflight]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
|
||||
expect(calls.some(c => c.path.includes("/hosts"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -106,6 +109,8 @@ it("quotes the supplied ID without guessing a host or suggesting a write replay"
|
||||
const body = JSON.parse(output.join("\n"));
|
||||
expect(body.hint).toContain(`rig queue show ${shellQuote(supplied)} --full --json`);
|
||||
expect(body.hint).not.toContain("queue update");
|
||||
expect(calls[0]?.path).toBe(`/api/queue/${encodeURIComponent(supplied)}/update`);
|
||||
expect(calls.slice(0, 2).map((c) => c.path)).toEqual([
|
||||
`/api/queue/${encodeURIComponent(supplied)}/transitions`, `/api/queue/${encodeURIComponent(supplied)}/update`,
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
@@ -42,6 +42,8 @@ describe("an agent's pane task: woken, claimed and closed (#165)", () => {
|
||||
return { status: response.status, data: await response.json() };
|
||||
};
|
||||
const client = {
|
||||
// The sender it stamps, as the real client reports it: the update's read-back checks the recorded actor against it.
|
||||
senderIdentity: async () => ({ session: process.env.OPENRIG_SESSION_NAME, originUnknown: false }),
|
||||
get: async (url: string) => request(url, { method: "GET" }),
|
||||
post: async (url: string, body: unknown) => request(url, { method: "POST", body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
import { queueCommand } from "../src/commands/queue.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
|
||||
type UpdateTransform = (body: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
/** The nth read of the row's transitions (0 is the cursor read before the write): a response, or a throw for no answer. */
|
||||
type TransitionsRead = (call: number) => { status: number; data: unknown } | undefined;
|
||||
|
||||
let database: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
|
||||
/** The selected daemon as the CLI sees it: the sender header it stamps, and the id the daemon's /healthz answers with. */
|
||||
interface Endpoint { sender?: string; selfHostId?: string }
|
||||
|
||||
async function runUpdate(qitemId: string, args: string[], transform: UpdateTransform = (body) => body, transitionsRead?: TransitionsRead,
|
||||
endpoint: Endpoint = {}) {
|
||||
let transitionReads = 0;
|
||||
let posts = 0;
|
||||
let exits = 0;
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const program = new Command();
|
||||
program.addCommand(queueCommand({
|
||||
lifecycleDeps: {
|
||||
spawn: () => ({ pid: 1, unref() {} }),
|
||||
fetch: async () => ({ ok: true }),
|
||||
kill: () => true,
|
||||
readFile: (file: string) => file === STATE_FILE
|
||||
? JSON.stringify({ pid: 1, port: 7433, host: "127.0.0.1", db: "test", startedAt: "test" })
|
||||
: null,
|
||||
writeFile() {},
|
||||
removeFile() {},
|
||||
exists: (file: string) => file === STATE_FILE,
|
||||
mkdirp() {},
|
||||
openForAppend: () => 3,
|
||||
isProcessAlive: () => true,
|
||||
},
|
||||
clientFactory: () => ({
|
||||
senderIdentity: async () => ({ session: endpoint.sender ?? "reviewer@fixture", originUnknown: false }),
|
||||
get: async (route: string) => route === "/healthz"
|
||||
? { status: 200, data: { selfHostId: endpoint.selfHostId } }
|
||||
: route.endsWith("/transitions")
|
||||
? transitionsRead?.(transitionReads++) ?? { status: 200, data: repo.listTransitions(qitemId) }
|
||||
: { status: 200, data: repo.getById(qitemId) },
|
||||
post: async (_route: string, rawBody: unknown) => {
|
||||
posts += 1;
|
||||
const body = transform(structuredClone(rawBody as Record<string, unknown>));
|
||||
return {
|
||||
status: 200,
|
||||
data: body === null
|
||||
? repo.getById(qitemId)
|
||||
: repo.update({ ...body, qitemId, actorSession: "reviewer@fixture" }),
|
||||
};
|
||||
},
|
||||
}) as never,
|
||||
}));
|
||||
// No exit status is reset here: each run's status is runProgram's to start clean.
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
console.log = (...values: unknown[]) => output.push(values.join(" "));
|
||||
console.error = (...values: unknown[]) => errors.push(values.join(" "));
|
||||
try {
|
||||
const code = await runProgram(program, ["node", "rig", "queue", "update", qitemId, ...args, "--json"], {
|
||||
out: (value) => output.push(value),
|
||||
err: (value) => errors.push(value),
|
||||
exit: () => { exits += 1; },
|
||||
});
|
||||
return { code, output, errors, row: repo.getByIdOrThrow(qitemId), transitions: repo.listTransitions(qitemId), posts, exits };
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
async function claimedRow(): Promise<string> {
|
||||
const row = await repo.create({
|
||||
sourceSession: "reviewer@fixture",
|
||||
destinationSession: "reviewer@fixture",
|
||||
body: "work",
|
||||
nudge: false,
|
||||
});
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "reviewer@fixture" });
|
||||
return row.qitemId;
|
||||
}
|
||||
|
||||
describe("queue update canonical read-back against daemon domain semantics", () => {
|
||||
beforeAll(() => {
|
||||
database = createDb();
|
||||
migrate(database, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(database);
|
||||
repo = new QueueRepository(database, bus, {
|
||||
validateRig: () => true,
|
||||
loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
});
|
||||
repo.attachOutbox(new OutboxHandler(database));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "reviewer@fixture");
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => database.close());
|
||||
|
||||
it("accepts a closing resolution appended after an existing fallback resolution", async () => {
|
||||
const row = await repo.create({
|
||||
sourceSession: "reviewer@fixture",
|
||||
destinationSession: "worker@fixture",
|
||||
body: "work",
|
||||
nudge: false,
|
||||
});
|
||||
repo.routeToFallback(row.qitemId, "reviewer@fixture", "worker unavailable");
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "reviewer@fixture" });
|
||||
|
||||
const result = await runUpdate(row.qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "Handled by reviewer",
|
||||
]);
|
||||
|
||||
expect(result.code, [...result.errors, ...result.output].join("\n")).toBe(0);
|
||||
expect(result.row.resolution).toBe("fallback: worker unavailable\nHandled by reviewer");
|
||||
});
|
||||
|
||||
it("accepts close, reopen, and reclose while preserving each resolution line", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const first = await runUpdate(qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "first outcome",
|
||||
]);
|
||||
const reopened = await runUpdate(qitemId, ["--state", "pending", "--reopen", "--note", "repair requested"]);
|
||||
const second = await runUpdate(qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "second outcome",
|
||||
]);
|
||||
|
||||
expect([first.code, reopened.code, second.code], [first.errors, reopened.errors, second.errors].flat().join("\n"))
|
||||
.toEqual([0, 0, 0]);
|
||||
expect(second.row.resolution).toBe("first outcome\nsecond outcome");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"2026-11-14T10:00:00Z",
|
||||
"2026-11-14T12:00:00+02:00",
|
||||
])("accepts equivalent deadline spelling %s after daemon normalization", async (deadline) => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, [
|
||||
"--closure-required-at", deadline, "--note", "Accepted revised deadline",
|
||||
]);
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.row.closureRequiredAt).toBe("2026-11-14T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects a genuinely different stored deadline", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
repo.update({
|
||||
qitemId,
|
||||
actorSession: "reviewer@fixture",
|
||||
closureRequiredAt: "2026-11-14T09:00:00Z",
|
||||
transitionNote: "initial deadline",
|
||||
});
|
||||
const result = await runUpdate(qitemId, [
|
||||
"--closure-required-at", "2026-11-14T10:00:00Z", "--note", "Accepted revised deadline",
|
||||
], () => null);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.errors.join("\n")).toContain("closureRequiredAt");
|
||||
});
|
||||
|
||||
it("rejects a close whose requested resolution was dropped", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "must persist",
|
||||
], (body) => {
|
||||
delete body.resolution;
|
||||
return body;
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.row.state).toBe("done");
|
||||
expect(result.row.resolution).toBeNull();
|
||||
expect(result.errors.join("\n")).toContain("resolution");
|
||||
});
|
||||
|
||||
it("requires a newer canonical transition carrying the requested note", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const before = repo.listTransitions(qitemId).length;
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], () => null);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.transitions).toHaveLength(before);
|
||||
expect(result.errors.join("\n")).toContain("no newer canonical transition carried requested note");
|
||||
});
|
||||
|
||||
// The cursor read before the write only proves a note is new afterwards. When it fails, the write is still sent and
|
||||
// persists; the receipt says the note can't be shown to be new and exits non-zero, even with an older transition
|
||||
// already carrying the same note.
|
||||
it.each([
|
||||
["doesn't answer", () => { throw new Error("socket hang up (fixture)"); }],
|
||||
["answers HTTP 503", () => ({ status: 503, data: { error: "busy" } })],
|
||||
["returns a malformed list", () => ({ status: 200, data: [{ transitionId: "seven" }] })],
|
||||
] as const)("sends a note-bearing update when the cursor read %s, and says the note is unproven", async (_label, fail) => {
|
||||
const qitemId = await claimedRow();
|
||||
repo.update({ qitemId, actorSession: "reviewer@fixture", transitionNote: "must persist" }); // an older matching note
|
||||
const before = repo.listTransitions(qitemId).length;
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist", "--summary", "kept summary"], undefined,
|
||||
(call) => call === 0 ? fail() : undefined);
|
||||
|
||||
expect(result.posts).toBe(1);
|
||||
expect(result.transitions).toHaveLength(before + 1);
|
||||
expect(result.transitions.at(-1)?.transitionNote).toMatch(/^must persist/);
|
||||
expect(result.row.summary).toBe("kept summary");
|
||||
expect(result.code).toBe(1);
|
||||
const said = result.errors.join("\n");
|
||||
expect(said).toContain("queue_update_readback_failed");
|
||||
expect(said).toContain("can't be shown to be new");
|
||||
expect(said).not.toContain("queue_update_preflight_failed");
|
||||
});
|
||||
|
||||
it("gives each run its own exit status: a failed update, then a successful one in the same process", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const failed = await runUpdate(qitemId, ["--note", "dropped"], () => null);
|
||||
const succeeded = await runUpdate(qitemId, ["--note", "kept"]);
|
||||
|
||||
expect(failed.code).toBe(1);
|
||||
expect(succeeded.code, succeeded.errors.join("\n")).toBe(0);
|
||||
// The action's failure is returned, never passed to process.exit(), which can cut off piped output.
|
||||
expect([failed.exits, succeeded.exits]).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
// A note is this write's only when this write's actor recorded it, as written or with what the daemon adds for it.
|
||||
it.each([
|
||||
["another writer's identical note", "other@fixture", "must persist"],
|
||||
["a lookalike note that starts the same way", "reviewer@fixture", "must persist (correction: still blocked)"],
|
||||
["a lookalike note on a new line", "reviewer@fixture", "must persist\nbut still blocked"],
|
||||
] as const)("doesn't take %s for a dropped note", async (_label, actor, note) => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], () => {
|
||||
repo.update({ qitemId, actorSession: actor, transitionNote: note });
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(result.transitions.at(-1)?.transitionNote).toBe(note);
|
||||
expect(result.code).toBe(1);
|
||||
const said = JSON.parse(result.errors.join("\n")) as { error: string; message: string };
|
||||
expect(said.error).toBe("queue_update_readback_failed");
|
||||
expect(said.message).toContain('no newer canonical transition carried requested note "must persist" from "reviewer@fixture"');
|
||||
});
|
||||
|
||||
// A seat on host-a writing to a daemon at a direct endpoint sends reviewer@fixture@host-a. Only a daemon whose own id is
|
||||
// host-a drops that suffix; any other daemon keeps it, and its own reviewer@fixture is a different seat.
|
||||
it("doesn't take the selected daemon's own same-named seat for a dropped note's writer", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], () => {
|
||||
repo.update({ qitemId, actorSession: "reviewer@fixture", transitionNote: "must persist" });
|
||||
return null;
|
||||
}, undefined, { sender: "reviewer@fixture@host-a", selfHostId: "host-b" });
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
const said = JSON.parse(result.errors.join("\n")) as { error: string; message: string };
|
||||
expect(said.message).toContain('from "reviewer@fixture@host-a"');
|
||||
expect(said.message).not.toContain('or "reviewer@fixture"');
|
||||
});
|
||||
|
||||
it("accepts the bare actor when the selected daemon answers with the sender's own host id", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], undefined, undefined,
|
||||
{ sender: "reviewer@fixture@host-a", selfHostId: "host-a" });
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.transitions.at(-1)).toMatchObject({ actorSession: "reviewer@fixture", transitionNote: "must persist" });
|
||||
});
|
||||
|
||||
it("accepts the summary suffix the daemon adds to a note", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist", "--summary", "new subject"]);
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.transitions.at(-1)?.transitionNote).toBe("must persist (summary updated)");
|
||||
});
|
||||
|
||||
it("accepts a note only after its canonical transition appears", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"]);
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.transitions.at(-1)?.transitionNote).toBe("must persist");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// A verified `rig queue update` prints the canonical row it read back, and keeps what only the write's response carries:
|
||||
// the park advisory (#2739). A real `queueCommand` talks over loopback HTTP to the real queueRoutes and QueueRepository,
|
||||
// so the POST and the GET that reads it back are the daemon's own.
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { queueRoutes } from "../../daemon/src/routes/queue.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { queueCommand } from "../src/commands/queue.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let server: any;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db);
|
||||
repo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next(); });
|
||||
app.route("/api/queue", queueRoutes());
|
||||
await new Promise<void>((resolve) => {
|
||||
server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db?.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// A seat, on this host's own daemon: the CLI stamps it as the sender. Ambient endpoints would win over the state file.
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-x@rig");
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
for (const key of ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT"]) vi.stubEnv(key, "");
|
||||
});
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
async function runCli(args: string[]): Promise<{ out: string; err: string; exit: number | undefined }> {
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const ol = console.log, oe = console.error;
|
||||
console.log = (...a: unknown[]) => { out.push(a.join(" ")); };
|
||||
console.error = (...a: unknown[]) => { err.push(a.join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await queueCommand({
|
||||
lifecycleDeps: {
|
||||
spawn: () => ({ pid: 1, unref() {} }), fetch: async () => ({ ok: true }), kill: () => true,
|
||||
readFile: (p: string) => (p === STATE_FILE ? JSON.stringify({ pid: 1, port, host: "127.0.0.1", db: "x", startedAt: "x" }) : null),
|
||||
writeFile() {}, removeFile() {}, exists: (p: string) => p === STATE_FILE, mkdirp() {}, openForAppend: () => 3, isProcessAlive: () => true,
|
||||
},
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
} as any).parseAsync(["node", "rig", ...args]);
|
||||
} finally {
|
||||
console.log = ol;
|
||||
console.error = oe;
|
||||
}
|
||||
const exit = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
return { out: out.join("\n"), err: err.join("\n"), exit };
|
||||
}
|
||||
|
||||
async function claimedRow(): Promise<string> {
|
||||
const row = await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "work", nudge: false });
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "dev-x@rig" });
|
||||
return row.qitemId;
|
||||
}
|
||||
|
||||
const park = (id: string) => ["update", id, "--state", "blocked", "--blocked-on", "external:vendor-fix", "--note", "waiting on the vendor"];
|
||||
|
||||
describe("a verified queue update keeps the write's park advisory", () => {
|
||||
it.each([
|
||||
["the compact JSON receipt", "true", ["--json"]],
|
||||
["the full JSON row", "false", ["--json"]],
|
||||
["the full row with --full", "true", ["--full", "--json"]],
|
||||
] as const)("in %s", async (_label, compact, flags) => {
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", compact);
|
||||
const id = await claimedRow();
|
||||
const { out, err, exit } = await runCli([...park(id), ...flags]);
|
||||
|
||||
expect(exit, err).toBeFalsy();
|
||||
const printed = JSON.parse(out) as { state?: string; advisories?: Array<{ code?: string }> };
|
||||
expect(printed.advisories).toEqual([expect.objectContaining({ code: "park_without_wake" })]);
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:vendor-fix" });
|
||||
});
|
||||
|
||||
it("in the compact text receipt", async () => {
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "true");
|
||||
const id = await claimedRow();
|
||||
const { out, err, exit } = await runCli(park(id));
|
||||
|
||||
expect(exit, err).toBeFalsy();
|
||||
expect(out).toMatch(/advisory: No wake is set for this park/);
|
||||
});
|
||||
|
||||
it("and a park with a wake carries none", async () => {
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "true");
|
||||
const id = await claimedRow();
|
||||
const { out, err, exit } = await runCli([...park(id), "--wake-after", "10m", "--json"]);
|
||||
|
||||
expect(exit, err).toBeFalsy();
|
||||
expect((JSON.parse(out) as { advisories?: unknown }).advisories).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,8 @@ function row(over: Row = {}, waiting: Row = {}): Row {
|
||||
}
|
||||
|
||||
function makeDeps(routes: Record<string, { status: number; data: unknown } | Array<{ status: number; data: unknown }>>): QueueDeps {
|
||||
const transitions: Array<{ transitionId: number; transitionNote: string | null; actorSession: string }> =
|
||||
[{ transitionId: 1, transitionNote: null, actorSession: "stub@rig" }];
|
||||
const pick = (key: string, fallback: { status: number; data: unknown }) => {
|
||||
const r = routes[key];
|
||||
return (Array.isArray(r) ? r.shift() : r) ?? fallback;
|
||||
@@ -56,8 +58,27 @@ function makeDeps(routes: Record<string, { status: number; data: unknown } | Arr
|
||||
return {
|
||||
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (p: string) => pick(`GET ${p}`, { status: 200, data: {} })),
|
||||
post: vi.fn(async (p: string) => pick(`POST ${p}`, { status: 500, data: { error: "unrouted" } })),
|
||||
// The sender this stub stamps; the transitions it records carry it as their actor, as the daemon does.
|
||||
senderIdentity: async () => ({ session: "stub@rig", originUnknown: false }),
|
||||
get: vi.fn(async (p: string) => {
|
||||
const explicit = routes[`GET ${p}`];
|
||||
if (explicit) return Array.isArray(explicit) ? explicit.shift()! : explicit;
|
||||
if (p.endsWith("/transitions")) return { status: 200, data: transitions };
|
||||
const update = routes[`POST ${p}/update`];
|
||||
if (update) {
|
||||
const response = Array.isArray(update) ? update[0] : update;
|
||||
return { status: 200, data: response?.data ?? {} };
|
||||
}
|
||||
return { status: 200, data: {} };
|
||||
}),
|
||||
post: vi.fn(async (p: string, body: unknown) => {
|
||||
const response = pick(`POST ${p}`, { status: 500, data: { error: "unrouted" } });
|
||||
const transitionNote = body && typeof body === "object" ? (body as { transitionNote?: unknown }).transitionNote : undefined;
|
||||
if (response.status < 400 && typeof transitionNote === "string") {
|
||||
transitions.push({ transitionId: transitions.length + 1, transitionNote, actorSession: "stub@rig" });
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
}) as unknown as ReturnType<QueueDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ interface StubResponse {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const STUB_SENDER = "stub@rig";
|
||||
|
||||
function makeDeps(opts?: {
|
||||
routes?: Record<string, StubResponse | StubResponse[]>;
|
||||
}): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
|
||||
@@ -41,10 +43,37 @@ function makeDeps(opts?: {
|
||||
deps: {
|
||||
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
// The sender this stub stamps; the transitions it synthesizes record it as their actor, as the daemon does.
|
||||
senderIdentity: async () => ({ session: STUB_SENDER, originUnknown: false }),
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
const route = routes[`GET ${path}`];
|
||||
return (Array.isArray(route) ? route.shift() : route) ?? { status: 200, data: {} };
|
||||
if (route) return Array.isArray(route) ? route.shift() : route;
|
||||
const transitionMatch = /^\/api\/queue\/([^/]+)\/transitions$/.exec(path);
|
||||
if (transitionMatch) {
|
||||
const qitemId = decodeURIComponent(transitionMatch[1]!);
|
||||
const update = [...calls].reverse().find((call) => call.method === "POST"
|
||||
&& call.path === `/api/queue/${encodeURIComponent(qitemId)}/update`);
|
||||
const transitionNote = update?.body && typeof update.body === "object"
|
||||
? (update.body as { transitionNote?: unknown }).transitionNote
|
||||
: undefined;
|
||||
return { status: 200, data: [
|
||||
{ transitionId: 1, transitionNote: null, actorSession: STUB_SENDER },
|
||||
...(typeof transitionNote === "string" ? [{ transitionId: 2, transitionNote, actorSession: STUB_SENDER }] : []),
|
||||
] };
|
||||
}
|
||||
const match = /^\/api\/queue\/([^/]+)$/.exec(path);
|
||||
if (match) {
|
||||
const qitemId = decodeURIComponent(match[1]!);
|
||||
const update = [...calls].reverse().find((call) => call.method === "POST"
|
||||
&& call.path === `/api/queue/${encodeURIComponent(qitemId)}/update`);
|
||||
if (update?.body && typeof update.body === "object") {
|
||||
const body = Object.fromEntries(Object.entries(update.body as Record<string, unknown>)
|
||||
.filter(([key, value]) => value !== undefined && !["transitionNote", "actorSession", "reopen"].includes(key)));
|
||||
return { status: 200, data: { qitemId, ...body } };
|
||||
}
|
||||
}
|
||||
return { status: 200, data: {} };
|
||||
}),
|
||||
getText: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
@@ -83,6 +112,7 @@ describe("rig queue CLI", () => {
|
||||
// AMBIENT OPENRIG_SESSION_NAME (which masked the env-less break in a managed runner). Per-test stubs
|
||||
// that need a specific seat override this.
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig");
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||||
@@ -1203,6 +1233,37 @@ describe("rig queue CLI", () => {
|
||||
evidenceRef: undefined,
|
||||
transitionNote: "audit bytes",
|
||||
});
|
||||
expect(calls.some((c) => c.method === "GET" && c.path === "/api/queue/qitem-note")).toBe(true);
|
||||
});
|
||||
|
||||
it("update reports success only after canonical read-back matches the requested state", async () => {
|
||||
const row = { qitemId: "qitem-verified", state: "in-progress", summary: "working" };
|
||||
const { deps, calls } = makeDeps({ routes: {
|
||||
"POST /api/queue/qitem-verified/update": { status: 200, data: { qitemId: "qitem-verified", state: "in-progress" } },
|
||||
"GET /api/queue/qitem-verified": { status: 200, data: row },
|
||||
} });
|
||||
await createProgram({ queueDeps: deps }).parseAsync([
|
||||
"node", "rig", "queue", "update", "qitem-verified", "--state", "in-progress", "--summary", "working", "--json",
|
||||
]);
|
||||
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
"POST /api/queue/qitem-verified/update",
|
||||
"GET /api/queue/qitem-verified",
|
||||
]);
|
||||
expect(JSON.parse(logs.at(-1)!)).toMatchObject(row);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("update fails closed when canonical read-back does not contain the requested state", async () => {
|
||||
const { deps } = makeDeps({ routes: {
|
||||
"POST /api/queue/qitem-mismatch/update": { status: 200, data: { qitemId: "qitem-mismatch", state: "in-progress" } },
|
||||
"GET /api/queue/qitem-mismatch": { status: 200, data: { qitemId: "qitem-mismatch", state: "pending" } },
|
||||
} });
|
||||
await createProgram({ queueDeps: deps }).parseAsync([
|
||||
"node", "rig", "queue", "update", "qitem-mismatch", "--state", "in-progress", "--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(JSON.parse(errors.at(-1)!)).toMatchObject({ error: "queue_update_readback_failed" });
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
|
||||
|
||||
@@ -62,12 +62,12 @@ function depsThrowing(err: Error): { queueDeps: QueueDeps } {
|
||||
async function runCli(args: string[], deps?: Parameters<typeof createProgram>[0]) {
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
let exitCode = 0;
|
||||
const program = createProgram(deps);
|
||||
await runProgram(program, ["node", "rig", ...args], {
|
||||
// The run's status is what runProgram returns; it calls exit only on its own error paths, with the same code.
|
||||
const exitCode = await runProgram(program, ["node", "rig", ...args], {
|
||||
out: (l) => out.push(l),
|
||||
err: (l) => err.push(l),
|
||||
exit: (c) => { exitCode = c; },
|
||||
exit: () => undefined,
|
||||
});
|
||||
return { out: out.join("\n"), err: err.join("\n"), exitCode };
|
||||
}
|
||||
@@ -122,6 +122,17 @@ describe("render response integrity — 3-part error + honest exit, never daemon
|
||||
beforeEach(() => { vi.stubEnv("OPENRIG_SESSION_NAME", "harness@rig"); process.exitCode = undefined; });
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
it("an unresolved queue actor stays nonzero when a wrapper pipes stdout", async () => {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
const { exitCode } = await runCli(
|
||||
["queue", "update", "qitem-x", "--state", "in-progress", "--json"],
|
||||
depsThrowing(new Error("daemon must not be contacted")),
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("bad-response renders fact/consequence/action JSON with nonzero exit (json)", async () => {
|
||||
const err = new DaemonResponseError(502, "<html>502 Bad Gateway</html>");
|
||||
const { out, exitCode } = await runCli(
|
||||
|
||||
@@ -241,6 +241,8 @@ describe("Send CLI", () => {
|
||||
// (Hermetic-gate default is env-UNSET, so without this env-less delivery would render the unknown
|
||||
// marker.) Nested describes that stub their own envs re-run after this; the block afterEach restores.
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "sender@my-rig");
|
||||
// A runner inside tmux would otherwise let the managed-pane fallback recover the runner's own seat.
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
import { senderIdentityHeaders } from "../src/client.js";
|
||||
import { bindingIsCurrent, resetSenderSession, resolveSenderSession, TMUX_IDENTITY_TIMEOUT_MS } from "../src/sender-identity.js";
|
||||
|
||||
const execFileSync = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:child_process", async (original) => ({ ...(await original<typeof import("node:child_process")>()), execFileSync }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
execFileSync.mockReset();
|
||||
resetSenderSession();
|
||||
});
|
||||
|
||||
function outsideASeatEnv(pane: string): void {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||||
vi.stubEnv("TMUX_PANE", pane);
|
||||
}
|
||||
|
||||
describe("managed sender identity recovery", () => {
|
||||
it("uses reconciled pane metadata when the live process missed the seat env", () => {
|
||||
outsideASeatEnv("%149");
|
||||
const metadata: Record<string, string> = { "@rigged_session_name": "producer-lead@studio", "@rigged_node_id": "node-1" };
|
||||
const read = (pane: string, option: string) => {
|
||||
expect(pane).toBe("%149");
|
||||
return metadata[option];
|
||||
};
|
||||
const bound = (nodeId: string, session: string) => nodeId === "node-1" && session === "producer-lead@studio";
|
||||
expect(resolveSenderSession(read, bound)).toBe("producer-lead@studio");
|
||||
expect(senderIdentityHeaders(undefined, undefined, read, bound)).toEqual({
|
||||
"X-OpenRig-Session": "producer-lead@studio",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the process seat env authoritative over pane metadata", () => {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-coder@studio");
|
||||
vi.stubEnv("TMUX_PANE", "%149");
|
||||
expect(resolveSenderSession(() => "producer-lead@studio", () => true)).toBe("dev-coder@studio");
|
||||
});
|
||||
|
||||
it("bounds the tmux read, and a read that times out leaves the seat unresolved", () => {
|
||||
outsideASeatEnv("%149");
|
||||
// The reader catches what the call throws, so the options are checked here, not inside the mock.
|
||||
execFileSync.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error("spawnSync tmux ETIMEDOUT"), { code: "ETIMEDOUT" });
|
||||
});
|
||||
expect(resolveSenderSession()).toBeUndefined();
|
||||
expect(execFileSync).toHaveBeenCalledTimes(1);
|
||||
expect(execFileSync.mock.calls[0]![2]).toMatchObject({ timeout: TMUX_IDENTITY_TIMEOUT_MS });
|
||||
expect(TMUX_IDENTITY_TIMEOUT_MS).toBeLessThanOrEqual(2_000);
|
||||
});
|
||||
|
||||
it("stays unresolved when neither env nor managed pane metadata exists", () => {
|
||||
outsideASeatEnv("%404");
|
||||
expect(resolveSenderSession(() => undefined, () => true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads the pane once per run: a command's preflight and its requests share it, and the next run reads again", async () => {
|
||||
outsideASeatEnv("%149");
|
||||
execFileSync.mockImplementation((_tmux: string, args: string[]) => args.at(-1) === "@rigged_session_name" ? "producer-lead@studio\n" : "node-1\n");
|
||||
const bound = () => true;
|
||||
const run = () => runProgram(new Command().action(() => {
|
||||
expect(resolveSenderSession(undefined, bound)).toBe("producer-lead@studio"); // the preflight
|
||||
expect(senderIdentityHeaders(undefined, undefined, undefined, bound)).toEqual({ "X-OpenRig-Session": "producer-lead@studio" });
|
||||
expect(senderIdentityHeaders("origin-a", undefined, undefined, bound)).toEqual({ "X-OpenRig-Session": "producer-lead@studio@origin-a" });
|
||||
}), ["node", "rig"], { exit: () => undefined });
|
||||
expect(await run()).toBe(0);
|
||||
expect(execFileSync).toHaveBeenCalledTimes(2); // the seat and its node, once
|
||||
expect(await run()).toBe(0);
|
||||
expect(execFileSync).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pane's seat holds only while the daemon binds it", () => {
|
||||
// A real claim sets the session's metadata and the daemon's binding. A real unclaim deletes the binding in its own
|
||||
// transaction even when clearing the metadata fails, which leaves the pane naming its old seat.
|
||||
async function claimedPane() {
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const setup = createTestApp(db);
|
||||
const metadata = new Map<string, string>();
|
||||
vi.mocked(setup.tmuxAdapter.setSessionOption).mockImplementation(async (_session: string, key: string, value: string) => {
|
||||
metadata.set(key, value);
|
||||
return { ok: true as const };
|
||||
});
|
||||
const rig = setup.rigRepo.createRig("adoption-fixture");
|
||||
const node = setup.rigRepo.addNode(rig.id, "external.helper", { runtime: "terminal" });
|
||||
const discovered = setup.discoveryRepo.upsertDiscoveredSession({
|
||||
tmuxSession: "manual-adoption", tmuxPane: "%1", cwd: "/tmp",
|
||||
activeCommand: "bash", runtimeHint: "terminal", confidence: "high",
|
||||
});
|
||||
const claim = await setup.claimService.bind({ discoveredId: discovered.id, rigId: rig.id, logicalId: node.logicalId });
|
||||
if (!claim.ok) throw new Error(claim.error);
|
||||
const read = (_pane: string, option: string) => metadata.get(option) || undefined;
|
||||
const bound = (nodeId: string, session: string) => bindingIsCurrent(db, nodeId, session);
|
||||
return { db, setup, claim, read, bound };
|
||||
}
|
||||
|
||||
it("reads a claimed session the daemon binds as its seat", async () => {
|
||||
outsideASeatEnv("%1");
|
||||
const { db, read, bound } = await claimedPane();
|
||||
expect(resolveSenderSession(read, bound)).toBe("manual-adoption");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("reads a released session as unknown when clearing its metadata failed", async () => {
|
||||
outsideASeatEnv("%1");
|
||||
const { db, setup, claim, read, bound } = await claimedPane();
|
||||
vi.mocked(setup.tmuxAdapter.setSessionOption).mockImplementation(async () => { throw new Error("tmux: no server"); });
|
||||
expect((await setup.rigLifecycleService.unclaimSession(claim.sessionId)).ok).toBe(true);
|
||||
expect(read("%1", "@rigged_session_name")).toBe("manual-adoption");
|
||||
expect(resolveSenderSession(read, bound)).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -311,15 +311,6 @@ describe("rig workflow CLI (PL-004 Phase D)", () => {
|
||||
expect(calls.find((c) => c.path === "/api/workflow/list?status=active")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes combined workflow filters and repeated tags to the list API", async () => {
|
||||
const url = "/api/workflow/list?status=active&project=p&mission=m&slice=s&rig=rig&name=wf&limit=4&tag=one&tag=two";
|
||||
const { deps, calls } = makeDeps({ routes: { [`GET ${url}`]: { status: 200, data: [] } } });
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "workflow", "list", "--status", "active", "--project", "p", "--mission", "m", "--slice", "s", "--rig", "rig", "--name", "wf", "--limit", "4", "--tag", "one", "--tag", "two", "--json"]);
|
||||
expect(calls.find((call) => call.path === url)).toBeDefined();
|
||||
});
|
||||
|
||||
it("show GETs /api/workflow/:instanceId", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/workflow/inst-1": { status: 200, data: { instanceId: "inst-1" } } },
|
||||
|
||||
+1
-4
@@ -106,10 +106,7 @@ You are about to lose every connection you have built. Spend this turn making th
|
||||
3. **Open the map with a summary** of 10 to 20 lines: who you are, what you hold, what mattered in this
|
||||
window, what is next and who authorizes it, and any hold in force. **Publish that summary as your seat
|
||||
recap** too: save it, plus a line naming the map's path, to a file and run
|
||||
`rig context recap-write --file <that file>`, which writes your own seat's recap. Give the file's literal absolute
|
||||
path, with no shell variable assignment or command substitution: Claude Code's allow rules don't match past an
|
||||
assignment (apart from a few known-safe variables), and an ask or deny rule still wins, so such a command can stop at
|
||||
an approval prompt, and OpenRig won't send `/compact` while it waits. If your world profile has a seat
|
||||
`rig context recap-write --file <that file>`, which writes your own seat's recap. If your world profile has a seat
|
||||
recap atom, it loads this recap, and an old recap would be served as if it were current.
|
||||
4. **Then write the connections.** Choose what this seat needs; these are examples, not a template:
|
||||
- **State:** rows you hold (id, state), branches and PR heads, the hold or release in force and who gave
|
||||
|
||||
@@ -95,6 +95,17 @@ rig queue handoff <qitem-id> \
|
||||
A queue write (`create`, `claim`, `update`, `handoff`, `handoff-and-complete`) answers with the row it wrote; a
|
||||
handoff answers with the closed row and its successor. When that answer shows what you need to establish, use it.
|
||||
|
||||
`queue update` is stricter: before it reports success, the CLI reads the row back from the canonical queue and checks
|
||||
every requested row field using daemon semantics (including normalized deadlines and appended closing resolutions).
|
||||
When `--note` is present, it first records the transition cursor and then requires a newer canonical transition,
|
||||
recorded under the actor the write was sent as, that carries those note bytes as written or with only what the daemon
|
||||
adds for that request (the reopen prefix, the summary suffix, a revised deadline's receipt). Another writer's note, or
|
||||
a note that only starts the same way, doesn't count. If that cursor can't be read, the write is still sent, and the
|
||||
note can't be shown to be new: the command says so and exits non-zero. A failed post-write check exits non-zero and
|
||||
says the write may have committed. Reconcile with `rig queue show <id> --full --json` before retrying. Preserve that
|
||||
exit status in wrappers, and use `set -o pipefail` when piping its output so a downstream formatter cannot turn
|
||||
failure into success.
|
||||
|
||||
`create` also prints `Queue create request ID: <id> (not proof of persistence)` on stderr *before* it sends the write,
|
||||
so an interrupted create can be reconciled. That ID names a request, not a row. The row is the `qitemId` on stdout
|
||||
when the command exits 0. A non-zero exit means no row was confirmed, even when stdout carries a `qitemId`: an
|
||||
|
||||
@@ -127,8 +127,8 @@ person needs in their project or home folder and name that path.
|
||||
joining as optional, with the effects below. Ask the person yes or no after this short explanation:
|
||||
- This adds me as a named member of the team, with my name on messages.
|
||||
- In a plain shell, joining writes no files in your project or personal settings.
|
||||
- Inside tmux, my sender and reply address is `<member>@<rig>`, even when your tmux session is named `0`;
|
||||
`rig attach --self` returns it and `rig whoami` confirms it; `--display-name` does not change it.
|
||||
- Inside tmux on 0.6.11, my sender and reply address is your existing tmux session name, even a number like `0`.
|
||||
`rig attach --self` returns that address and `rig whoami` confirms it; `--display-name` does not change it.
|
||||
- Inside tmux, other agents can type into this window, and stopping the team (`rig down`) also closes this tmux
|
||||
session (both from source, not hand-tested). Transcript recording and, for Claude Code, writing
|
||||
`.openrig/context-collector.cjs` and `.claude/settings.local.json` for the status line were hand-tested on 0.6.11.
|
||||
@@ -147,8 +147,7 @@ person needs in their project or home folder and name that path.
|
||||
`rig queue create --source <their id>@external --destination <seat> --summary "<one line>" --body-file <file>`: the
|
||||
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
|
||||
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
|
||||
shell above is the route. For work whose questions and result must come back to this session, use a return
|
||||
address ("If the person stays with you" below), not a declared name.
|
||||
shell above is the route.
|
||||
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
|
||||
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
|
||||
name someone else), and don't offer again.
|
||||
@@ -169,10 +168,9 @@ person needs in their project or home folder and name that path.
|
||||
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
|
||||
(never the logical ID or a guessed name) and send:
|
||||
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
|
||||
If the person stays here, hand the goal over as a task from your own address instead, so the operator's questions
|
||||
and the result come back to you ("If the person stays with you" below). Carry their constraints, which logins they
|
||||
have (an installed provider that isn't signed in doesn't count), and any permission choice they made, chosen or
|
||||
declined, in either handoff.
|
||||
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
|
||||
results to relay." Carry their constraints, which logins they have (an installed provider that isn't signed in
|
||||
doesn't count), and any permission choice they made, chosen or declined, in either handoff.
|
||||
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
|
||||
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
|
||||
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
|
||||
@@ -183,54 +181,11 @@ person needs in their project or home folder and name that path.
|
||||
Check those logins (`claude auth status`, `codex login status`), or go by what the person told you. If one is missing
|
||||
or you can't tell, say so before launching and let them choose: the operator adapts the team to the logins they have,
|
||||
or they sign in first.
|
||||
- **If the person stays with you** (unless they declined the Codex offer below and explicitly chose the operator
|
||||
route), you're their front door to the team: you hand it the goal, bring its questions to them and bring back what
|
||||
it produced, through the task queue rather than the operator's screen.
|
||||
- **Your return address:** if this session joined the team (above), it's the `OPENRIG_SESSION_NAME` that
|
||||
`rig attach` printed. Otherwise agree one with the person on the kernel's rig, such as `claude-app@kernel` or
|
||||
`codex-app@kernel`. It's an address the queue takes tasks for; nothing is launched for it and no file is written.
|
||||
If the person keeps more than one of these conversations, give each its own address (for example
|
||||
`claude-app-2@kernel`); one address shares its questions.
|
||||
To tell the team what it's for, the person can add it to a roster as a hosted member
|
||||
(`docs/reference/hosted-members.md` in the OpenRig package).
|
||||
Put it in front of every `rig queue` command: `OPENRIG_SESSION_NAME=<address> rig queue ...` (add
|
||||
`OPENRIG_HOME=<home>` when OpenRig doesn't use the default home). A `--source` name only labels the one task it's
|
||||
on: claiming and answering the team's questions need the address in `OPENRIG_SESSION_NAME`.
|
||||
- **Hand over the goal:** write it to a file with what the message above carries (that you're the agent that
|
||||
installed OpenRig and the person stays with you, the goal, folder, branch, constraints, logins and permission
|
||||
choices), and end it with this paragraph, filling in your address:
|
||||
|
||||
> To ask me anything, create a queue item to `<address>` (`rig queue create --destination <address> ...`), then
|
||||
> block this item on that new item's id (`rig queue block <this id> --on <question id>`), so my answer wakes you. I
|
||||
> check it when the person asks. Don't ask in your own terminal: the person isn't there. If you hand this item on,
|
||||
> copy this paragraph into the new item. When you finish, put the outcome in this item's resolution and any file in
|
||||
> its evidence ref.
|
||||
|
||||
Then run
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <canonicalSessionName> --summary "<the goal in a sentence>" --body-file <file> --verify`.
|
||||
Tell the person the task ID it prints, and keep the goal, your address and that ID: they're how you find this work
|
||||
again.
|
||||
- **The team's questions** come to your address as tasks:
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue list --owned --state pending,in-progress`. Read each in full
|
||||
(`rig queue show <id> --full`) and show the person the whole question in the team's words. Record only what they
|
||||
tell you, a condition included ("yes, but only on a branch"): claim it, then
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue update <id> --state done --closure-reason no-follow-on --resolution "<their words>"`.
|
||||
Add no label, quotation marks or notes of your own, and don't turn their words into a yes or a no: the agent that
|
||||
asked decides what they mean. You speak as yourself, never as the person. If they aren't there to answer, leave
|
||||
it open and tell them it's waiting; never answer for them.
|
||||
- **A question only on the operator's screen:** if the operator asks in its pane and nothing reached your address,
|
||||
read it with `rig capture <canonicalSessionName>`, show the person, and send their words as a new task to the
|
||||
operator that names your task's ID, not as keys typed into its pane.
|
||||
- **Delivery isn't the task's state.** A task's state says where the work is: pending (created and readable, not
|
||||
yet claimed), claimed or in progress (an agent took it), handed off (follow the next item) or done (read its
|
||||
resolution). Whether its agent was woken is a separate fact, the wake outcome `--verify` reports: `verified` (it
|
||||
rendered in the seat's pane) or `claimed` (picked up); `delivered-ack-pending` (sent, render unconfirmed: not
|
||||
proof it was woken); `retained` (the seat's typing guard held the wake, the task is still there to read, and it
|
||||
isn't retried automatically: wake the task's destination once its input is free with
|
||||
`rig send <destination> "task <id> is yours"`); `failed` (the wake didn't land, and the task is still
|
||||
owed: wake it the same way); `indeterminate` or `still-pending` (unknown, neither failed nor delivered: check
|
||||
before you wake it again). A pending task with no wake isn't lost or done.
|
||||
- **Nothing wakes this chat** when the team asks or finishes: check your address when the person asks or comes back.
|
||||
- **Relay the operator's questions** unless the person declined the Codex offer below and explicitly chose the operator route: the operator asks in its own pane, and the person may not be looking there.
|
||||
After each step you hand it, read its screen with `rig capture <canonicalSessionName>`. When it asks the person
|
||||
something (start this team? which folder? which option?), ask them here in the operator's words and send their
|
||||
answer back with `rig send`, or tell them to answer in the operator's pane. Unlike a prompt or a menu, a question
|
||||
doesn't show up as a stopped seat, so don't leave it to a background wait.
|
||||
- **If the operator answers with a model error,** such as "… not supported when using Codex with a ChatGPT account", its
|
||||
Codex sign-in works but that account can't run the model. OpenRig put the operator on Codex because the sign-in
|
||||
passed. Tell the person which model failed. If Claude Code is signed in, offer to move the operator to it; on their
|
||||
@@ -261,9 +216,9 @@ person needs in their project or home folder and name that path.
|
||||
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
|
||||
Another kind of menu you can answer with
|
||||
the key that screen shows, or ask the person. Capture it again before sending anything more.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Their questions and work for you arrive
|
||||
at your return address ("If the person stays with you" above). `rig capture <seat>@<rig>` shows what an agent is
|
||||
doing; it isn't how answers reach you.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
|
||||
`rig queue list --destination <your address>` and `rig queue show <id> --full`, and read an agent's screen with
|
||||
`rig capture <seat>@<rig>`.
|
||||
|
||||
### Codex: one offer, once, after OpenRig starts
|
||||
|
||||
@@ -302,29 +257,21 @@ offer, so the person sees the real file, not a placeholder:
|
||||
|
||||
Follow these steps by default. Only when the person declined the Codex offer above and explicitly chose the operator route does the operator own this follow-through.
|
||||
|
||||
- **Find the team's task:** if you gave the operator a task, start from it: when the operator hands it to the team's
|
||||
lead, `rig queue show <id> --json` lists the next item under `successors`. Otherwise, or if the operator started a
|
||||
new task for the lead (`dev-build@starter`, or `orch-lead` in `workshop` and `factory`; for starter and factory,
|
||||
`rig specs preview <name> --kind rig` says what each seat does; for a running team, `rig ps --nodes --rig <rig>`),
|
||||
find it with `rig queue list --destination <seat>@<rig> -a`.
|
||||
- **Find the team's task:** the operator gives the goal to the team's lead as a queue task: `dev-build@starter`, or
|
||||
`orch-lead` in `workshop` and `factory` (for starter and factory, `rig specs preview <name> --kind rig` says what
|
||||
each seat does; for a running team, `rig ps --nodes --rig <rig>`). Find it with
|
||||
`rig queue list --destination <seat>@<rig> -a`.
|
||||
- **Follow it to the result:** a task that changed hands isn't finished. `rig queue show <id> --full` names the seat it
|
||||
went to, and that seat's next task is the one whose `handedOffFrom` is that ID
|
||||
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result,
|
||||
and report what the record says; `rig capture <seat>@<rig>` only shows what an agent is doing right now. Then read
|
||||
what it produced and tell the person what the team actually did (the branch, commit, review or PR text), not just
|
||||
that you sent it.
|
||||
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result, and
|
||||
use `rig capture <seat>@<rig>` to see what it's doing. Then read what it produced and tell the person what the team
|
||||
actually did (the branch, commit, review or PR text), not just that you sent it.
|
||||
- **Give the team more work:** write the goal to a file: what to change, in which repository and branch, what done
|
||||
looks like, and that the result comes back to you (the branch or commit, the review, any PR text), ending with the
|
||||
reply paragraph above. Then
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file> --verify`.
|
||||
A message informs, and a queue task is the work someone owns. Read its wake outcome as above before you send
|
||||
anything else.
|
||||
looks like, and that the result comes back to you (the branch or commit, the review, any PR text). Then
|
||||
`rig queue create --source <your name> --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file>`, and tell that agent with
|
||||
`rig send <seat>@<rig> "task <id> is yours"`. A message informs, and a queue task is the work someone owns.
|
||||
`--source` names you when this session hasn't joined the team.
|
||||
- **Then ask what's next.** If no team can take the work, fix that or ask the person. Don't quietly do it yourself.
|
||||
- **Coming back later:** in a new session, use the same return address and the task IDs you gave the person. Read
|
||||
each with `rig queue show <id> --json` and follow its `successors` to the newest item, as above;
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue list --mine -a` lists everything this address sent or owns, finished work
|
||||
included (without `-a` it shows only open items). Follow that work rather than creating it again.
|
||||
Adding OpenRig's MCP plugin to this app later starts a new session; it doesn't carry this conversation over.
|
||||
- **Other ways to work with the team:** `rig send <seat>@<rig> "..."` types into an agent's terminal,
|
||||
`rig capture <seat>@<rig>` reads its screen, and `rig ps --nodes --rig <rig>` shows who's on it.
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { execRuntimeProbe } from "./runtime-probe-exec.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { codexPostureArg } from "./yolo-mode.js";
|
||||
import { observeCodexSandbox } from "../domain/permission-drift.js";
|
||||
import { codexMemberLaunch, type CodexMemberPermissions } from "../domain/codex-member-permissions.js";
|
||||
import { codexPolicyWarning, resolveCodexFilesystemHome, verifyCodexFilesystemPolicy, type CodexFilesystemVerifier } from "../domain/codex-filesystem-preflight.js";
|
||||
import { codexEnvelopeArgs } from "../domain/codex-rebuild-permissions.js";
|
||||
import { codexMemberEnvelope, observeCodexEnvelope, type CodexMemberPermissions } from "../domain/codex-member-permissions.js";
|
||||
import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js";
|
||||
import { codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js";
|
||||
|
||||
@@ -40,7 +40,6 @@ interface CodexResumeOptions {
|
||||
readNetworkDefault?: CodexNetworkDefaultReader;
|
||||
/** launch.codex_login_shell: true keeps Codex's own login shells for tool commands. Absent: off. */
|
||||
codexLoginShell?: () => boolean;
|
||||
verifyFilesystemPolicy?: CodexFilesystemVerifier;
|
||||
/** Ensure the managed Codex config (hooks, trust) just before a resume, as a runtime-adapter launch does, in the
|
||||
* seat's own home when it has one. */
|
||||
ensureManagedConfig?: (codexHome?: string) => Promise<void>;
|
||||
@@ -94,17 +93,7 @@ export class CodexResumeAdapter {
|
||||
if (wrapped && !this.options.seatLaunchEnvironment) {
|
||||
return { ok: false, code: "resume_failed", message: "launch_wrapper support unavailable; no agent started." };
|
||||
}
|
||||
const filesystem = codexPermissions && "filesystem" in codexPermissions ? codexPermissions : undefined;
|
||||
const codexHome = seatCodexHome ?? this.options.codexHome ?? (filesystem ? resolveCodexFilesystemHome() : undefined);
|
||||
let policyWarning: string | undefined;
|
||||
if (filesystem) {
|
||||
try {
|
||||
const verified = await (this.options.verifyFilesystemPolicy ?? verifyCodexFilesystemPolicy)({ permissions: filesystem, cwd, codexHome, launchPath: this.options.launchPath, launchWrapper,
|
||||
codexConfigProfile: codexConfigProfile?.trim() || undefined });
|
||||
if (!verified.ok) return { ok: false, code: "resume_failed", message: verified.error };
|
||||
if (!verified.verified) policyWarning = codexPolicyWarning(verified.reason);
|
||||
} catch { policyWarning = codexPolicyWarning("Native Codex configuration reader failed"); }
|
||||
}
|
||||
const codexHome = seatCodexHome ?? this.options.codexHome;
|
||||
|
||||
// Only a seat's own home is passed on, so a call for a seat without one is exactly as before.
|
||||
await (seatCodexHome ? this.options.ensureManagedConfig?.(seatCodexHome) : this.options.ensureManagedConfig?.());
|
||||
@@ -131,16 +120,15 @@ export class CodexResumeAdapter {
|
||||
}
|
||||
|
||||
const profileArg = codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : "";
|
||||
const choice = codexPermissions ? codexMemberLaunch(codexPermissions) : undefined;
|
||||
const posture = choice ? profileArg + choice.args : codexPostureArg(profileArg, process.env, resolvedPosture);
|
||||
const appliedLaunch = choice ? choice.observation : observeCodexSandbox(posture);
|
||||
if (policyWarning) appliedLaunch.reason = policyWarning;
|
||||
const postureArg = posture + operationalLaunchArg("codex", choice ? { nonInterruptive }
|
||||
const envelope = codexPermissions ? codexMemberEnvelope(codexPermissions) : undefined;
|
||||
const posture = envelope ? profileArg + codexEnvelopeArgs(envelope) : codexPostureArg(profileArg, process.env, resolvedPosture);
|
||||
const appliedLaunch = envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture);
|
||||
const postureArg = posture + operationalLaunchArg("codex", envelope ? { nonInterruptive }
|
||||
: { kernelAuthority, teamPermissionDefault, nonInterruptive, launchPosture: resolvedPosture });
|
||||
const readNetworkDefault = this.options.readNetworkDefault;
|
||||
const networkArg = choice ? "" : await codexNetworkDefaultArg(seatCodexHome && readNetworkDefault ? ((dir: string) => readNetworkDefault(dir, seatCodexHome)) : readNetworkDefault,
|
||||
const networkArg = envelope ? "" : await codexNetworkDefaultArg(seatCodexHome && readNetworkDefault ? ((dir: string) => readNetworkDefault(dir, seatCodexHome)) : readNetworkDefault,
|
||||
appliedLaunch, cwd, tmuxSessionName);
|
||||
const workspaceArg = !choice && teamPermissionDefault && !codexConfigProfile?.trim()
|
||||
const workspaceArg = !envelope && teamPermissionDefault && !codexConfigProfile?.trim()
|
||||
&& appliedLaunch.state === "observed" && appliedLaunch.value === "workspace-write"
|
||||
? codexTeamWorkspaceArg(this.options.prepareTeamWorkspace, tmuxSessionName) : "";
|
||||
// The seat's own writable roots, as a launch gets them: its git metadata dirs (so it can still commit) and its topology
|
||||
@@ -164,7 +152,7 @@ export class CodexResumeAdapter {
|
||||
|
||||
const launchEnv = [this.options.launchPath ? `PATH=${shellQuote(this.options.launchPath)}` : "", codexHome ? `CODEX_HOME=${shellQuote(codexHome)}` : ""].filter(Boolean);
|
||||
const staged = this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { codexCwd: cwd, runtime: "codex", ...((filesystem ? codexHome : seatCodexHome) ? { codexHome: filesystem ? codexHome : seatCodexHome } : {}), ...(wrapped ? { launchWrapper: wrapped } : {}) })
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { codexCwd: cwd, runtime: "codex", ...(seatCodexHome ? { codexHome: seatCodexHome } : {}), ...(wrapped ? { launchWrapper: wrapped } : {}) })
|
||||
: launchEnv.length ? `env ${launchEnv.join(" ")} ${cmd}` : cmd;
|
||||
if (beforeNativeDispatch && !beforeNativeDispatch()) return { ok: false, code: "attention_required", message: "Selected history changed during Codex resume preparation; no native command was sent." };
|
||||
const textResult = beforeNativeDispatch
|
||||
@@ -176,7 +164,7 @@ export class CodexResumeAdapter {
|
||||
|
||||
const result = await this.verifyResume(tmuxSessionName);
|
||||
const launchFacts = daemonSupport ? { ...appliedLaunch, daemonOptOut: daemonSupport.kind === "supported" } : appliedLaunch;
|
||||
return result.ok ? { ...result, appliedLaunch: launchFacts, ...(policyWarning ? { warnings: [policyWarning] } : {}) } : result;
|
||||
return result.ok ? { ...result, appliedLaunch: launchFacts } : result;
|
||||
}
|
||||
|
||||
// Mirrors ClaudeResumeAdapter.verifyResume: poll the pane, run the native
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { codexEnvelopeArgs } from "../domain/codex-rebuild-permissions.js";
|
||||
import { codexMemberLaunch, observeCodexEnvelope, readProfileFingerprint, type CodexProfileObservation, type ProfileFingerprint } from "../domain/codex-member-permissions.js";
|
||||
import { codexPolicyWarning, resolveCodexFilesystemHome, verifyCodexFilesystemPolicy, type CodexFilesystemDecision, type CodexFilesystemVerifier } from "../domain/codex-filesystem-preflight.js";
|
||||
import { codexMemberEnvelope, observeCodexEnvelope, readProfileFingerprint, type CodexProfileObservation, type ProfileFingerprint } from "../domain/codex-member-permissions.js";
|
||||
import { operationalLaunchArg } from "./kernel-authority.js";
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
@@ -177,10 +176,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// the real probe by default.
|
||||
private verifyProfilePreflight: (profile: string, cwd: string, home?: string) => Promise<CodexProfileProbeResult>;
|
||||
private profileFingerprint: (path: string) => Promise<ProfileFingerprint>;
|
||||
private verifyFilesystemPolicy: CodexFilesystemVerifier;
|
||||
// A handover checks the exact binding before retiring its predecessor. Consume
|
||||
// that result once at dispatch, rather than repeat a refusal after cutover.
|
||||
private filesystemPreflight = new WeakMap<NodeBinding, CodexFilesystemDecision>();
|
||||
// #69: whether the installed Codex supports --no-daemon. Startup wires the real probe;
|
||||
// absent (unit tests, other embedders) keeps the existing invocation unchanged.
|
||||
private detectDaemonSupport?: CodexDaemonSupportDetector;
|
||||
@@ -243,7 +238,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
verifyProfilePreflight?: (profile: string, cwd?: string, home?: string) => Promise<CodexProfileProbeResult>;
|
||||
/** The setter's optional profile hash; defaults to a bounded, non-blocking read. */
|
||||
profileFingerprint?: (path: string) => Promise<ProfileFingerprint>;
|
||||
verifyFilesystemPolicy?: CodexFilesystemVerifier;
|
||||
detectDaemonSupport?: CodexDaemonSupportDetector;
|
||||
readNetworkDefault?: CodexNetworkDefaultReader;
|
||||
codexLoginShell?: () => boolean;
|
||||
@@ -281,7 +275,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
this.codexHome = deps.codexHome;
|
||||
this.seatHome = deps.seatHome;
|
||||
this.profileFingerprint = deps.profileFingerprint ?? readProfileFingerprint;
|
||||
this.verifyFilesystemPolicy = deps.verifyFilesystemPolicy ?? verifyCodexFilesystemPolicy;
|
||||
this.launchPath = deps.launchPath;
|
||||
this.detectDaemonSupport = deps.detectDaemonSupport;
|
||||
this.readNetworkDefault = deps.readNetworkDefault;
|
||||
@@ -316,10 +309,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
/** The home a seat's Codex uses: its own from its member or rig `env:`, else the daemon's explicit CODEX_HOME. Undefined
|
||||
* keeps the legacy OS/PID home behavior. */
|
||||
private homeFor(binding: { nodeId?: string; accountHome?: string | null; codexPermissions?: NodeBinding["codexPermissions"] } | undefined): string | undefined {
|
||||
const selected = this.ownHome(binding) ?? this.codexHome;
|
||||
return binding?.codexPermissions && "filesystem" in binding.codexPermissions
|
||||
? selected ?? resolveCodexFilesystemHome(undefined, this.fs.homedir) : selected;
|
||||
private homeFor(binding: { nodeId?: string; accountHome?: string | null } | undefined): string | undefined {
|
||||
return this.ownHome(binding) ?? this.codexHome;
|
||||
}
|
||||
|
||||
/** Only the seat's own CODEX_HOME, undefined when it declares none. Probes and readers get it as an extra argument
|
||||
@@ -328,8 +319,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return binding?.accountHome ?? (binding?.nodeId ? this.seatHome?.(binding.nodeId, binding.accountHome === null) : undefined);
|
||||
}
|
||||
|
||||
private ownHomeCommandOpt(binding: NodeBinding): { codexHome?: string } {
|
||||
const own = binding.codexPermissions && "filesystem" in binding.codexPermissions ? this.homeFor(binding) : this.ownHome(binding);
|
||||
private ownHomeCommandOpt(binding: { nodeId?: string; accountHome?: string | null }): { codexHome?: string } {
|
||||
const own = this.ownHome(binding);
|
||||
return own ? { codexHome: own } : {};
|
||||
}
|
||||
|
||||
@@ -907,36 +898,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return { delivered, failed, ...(warnings.length ? { warnings } : {}) };
|
||||
}
|
||||
|
||||
/** No managed config writes, pane input, termination or bindings change here. */
|
||||
async preflightLaunch(binding: NodeBinding): Promise<{ ok: true; warnings?: string[] } | { ok: false; error: string }> {
|
||||
const decision = await this.checkFilesystemPolicy(binding);
|
||||
if (!decision) return { ok: true };
|
||||
if (!decision.ok) return decision;
|
||||
const profile = await this.checkProfileLoad(binding);
|
||||
if (!profile.ok) return profile;
|
||||
this.filesystemPreflight.set(binding, decision);
|
||||
return decision.verified ? { ok: true } : { ok: true, warnings: [codexPolicyWarning(decision.reason)] };
|
||||
}
|
||||
|
||||
private async checkFilesystemPolicy(binding: NodeBinding): Promise<CodexFilesystemDecision | undefined> {
|
||||
if (!binding.codexPermissions || !("filesystem" in binding.codexPermissions)) return undefined;
|
||||
try {
|
||||
return await this.verifyFilesystemPolicy({ permissions: binding.codexPermissions, cwd: binding.cwd,
|
||||
codexHome: this.homeFor(binding), launchPath: this.launchPath, launchWrapper: binding.launchWrapper,
|
||||
codexConfigProfile: binding.codexConfigProfile?.trim() || undefined });
|
||||
} catch {
|
||||
return { ok: true, verified: false, reason: "Native Codex configuration reader failed" };
|
||||
}
|
||||
}
|
||||
|
||||
private async checkProfileLoad(binding: NodeBinding): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const profile = binding.codexConfigProfile?.trim();
|
||||
if (!profile) return { ok: true };
|
||||
const own = this.ownHome(binding);
|
||||
const probe = await (own ? this.verifyProfilePreflight(profile, binding.cwd, own) : this.verifyProfilePreflight(profile, binding.cwd));
|
||||
return probe.ok ? { ok: true } : { ok: false, error: `${probe.error}${probe.migrationHint ? `\n Fix: ${probe.migrationHint}` : ""}` };
|
||||
}
|
||||
|
||||
async launchHarness(
|
||||
binding: NodeBinding,
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; beforeNativeDispatch?: () => boolean },
|
||||
@@ -960,7 +921,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const prepared = await this.prepareLaunch(binding, opts.name, undefined, { installCorePlugin: !opts.resumeToken });
|
||||
if (!prepared.ok) return prepared;
|
||||
const { modelArg, effortArg, appliedLaunch, postureArg, queueStateDirArg, gitDirArg, seatFolderArg, daemonArg, networkArg, loginShellArg } = prepared;
|
||||
const policyNotice = appliedLaunch.reason?.startsWith("policy not verified:") ? { warnings: [appliedLaunch.reason] } : {};
|
||||
|
||||
// Fork branch: `codex fork <parent_thread_id>`. Captures the NEW thread id
|
||||
// post-fork. Parent thread id is NOT persisted onto the new seat record
|
||||
@@ -1001,7 +961,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// For a handover a missed capture is pending metadata, not a failed launch: the fork returns
|
||||
// without a token and the handover's readiness checks still decide success. Any other fork
|
||||
// keeps its unsent context pending for `rig seat continue`, which verifies the child first.
|
||||
if (opts.forkSource.pendingIdAllowed) return { ok: true, appliedLaunch, ...policyNotice };
|
||||
if (opts.forkSource.pendingIdAllowed) return { ok: true, appliedLaunch };
|
||||
return {
|
||||
ok: false,
|
||||
error: "codex fork: could not capture new post-fork thread id",
|
||||
@@ -1009,7 +969,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
forkIdentityPending: true,
|
||||
};
|
||||
}
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch, ...policyNotice };
|
||||
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
|
||||
@@ -1036,17 +996,17 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (opts.resumeToken) {
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession, updatePrompt, { resumeToken: opts.resumeToken, ...this.ownHomeOpt(binding) });
|
||||
if (!verification.ok) return verification;
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch, ...policyNotice,
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch,
|
||||
...(verification.resumedProcess ? { resumedProcess: verification.resumedProcess } : {}) };
|
||||
}
|
||||
|
||||
const threadId = await this.captureFreshThreadId(binding, updatePrompt);
|
||||
if (updatePrompt.failure) return updatePrompt.failure;
|
||||
if (threadId) {
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch, ...policyNotice };
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
|
||||
}
|
||||
|
||||
return { ok: true, appliedLaunch, ...policyNotice };
|
||||
return { ok: true, appliedLaunch };
|
||||
}
|
||||
|
||||
/** The command launchHarness would type to resume `resumeToken`, wrapped in the seat launch environment,
|
||||
@@ -1054,7 +1014,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
async buildResumeCommand(
|
||||
binding: NodeBinding,
|
||||
opts: { name: string; resumeToken: string; running?: RunningCodexLaunch; launchShell?: string },
|
||||
): Promise<{ ok: true; command: string; core: string; appliedLaunch: AppliedLaunchObservation; warnings?: string[] } | { ok: false; error: string }> {
|
||||
): Promise<{ ok: true; command: string; core: string; appliedLaunch: AppliedLaunchObservation } | { ok: false; error: string }> {
|
||||
if (binding.launchWrapper?.length && !this.seatLaunchEnvironment) {
|
||||
return { ok: false, error: "launch_wrapper support unavailable; no agent started." };
|
||||
}
|
||||
@@ -1069,8 +1029,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
} catch {
|
||||
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
|
||||
}
|
||||
return { ok: true, command, core, appliedLaunch: prepared.appliedLaunch,
|
||||
...(prepared.appliedLaunch.reason?.startsWith("policy not verified:") ? { warnings: [prepared.appliedLaunch.reason] } : {}) };
|
||||
return { ok: true, command, core, appliedLaunch: prepared.appliedLaunch };
|
||||
}
|
||||
|
||||
/** Type a command buildResumeCommand built into the pane's shell, then verify the resume as launchHarness
|
||||
@@ -1121,10 +1080,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
* prepared parts come from the running Codex instead, and no Codex process is started to decide them. */
|
||||
private async prepareLaunch(binding: NodeBinding, name: string, running: RunningCodexLaunch | undefined,
|
||||
opts: { installCorePlugin: boolean }): Promise<PreparedCodexLaunch | { ok: false; error: string }> {
|
||||
const checkedBeforeCutover = this.filesystemPreflight.get(binding);
|
||||
const decision = checkedBeforeCutover ?? await this.checkFilesystemPolicy(binding);
|
||||
this.filesystemPreflight.delete(binding);
|
||||
if (decision && !decision.ok) return decision;
|
||||
// The config write and, for a seat that selected openrig-core, its install run as one step under the config lock, so
|
||||
// concurrent launches never interleave them. An unselected seat's launch never installs.
|
||||
// A seat with its own CODEX_HOME gets OpenRig's managed config (hooks, feature flag) and plugin in that home.
|
||||
@@ -1143,17 +1098,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const effortArg = effort ? ` -c ${shellQuote(`model_reasoning_effort="${effort}"`)}` : "";
|
||||
const profile = binding.codexConfigProfile?.trim();
|
||||
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
|
||||
const filesystemPermissions = binding.codexPermissions && "filesystem" in binding.codexPermissions ? binding.codexPermissions : undefined;
|
||||
// An authored filesystem policy always wins over a stale observed legacy envelope. Legacy rebuilds keep their carry behavior.
|
||||
const memberChoice = binding.codexPermissions && (!binding.codexRebuildPermissions || filesystemPermissions) ? codexMemberLaunch(binding.codexPermissions) : undefined;
|
||||
const envelope = memberChoice ? undefined : binding.codexRebuildPermissions;
|
||||
const posture = memberChoice ? profileArg + memberChoice.args : envelope
|
||||
// A handover rebuild's observed predecessor envelope, else the seat's own envelope, replaces the posture decision.
|
||||
const memberEnvelope = binding.codexRebuildPermissions || !binding.codexPermissions ? undefined : codexMemberEnvelope(binding.codexPermissions);
|
||||
const envelope = binding.codexRebuildPermissions ?? memberEnvelope;
|
||||
const posture = envelope
|
||||
? profileArg + codexEnvelopeArgs(envelope)
|
||||
: codexPostureArg(profileArg, process.env, binding.launchPosture);
|
||||
const appliedLaunch: AppliedLaunchObservation = memberChoice?.observation ?? (envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture));
|
||||
if (decision && !decision.verified) appliedLaunch.reason = codexPolicyWarning(decision.reason);
|
||||
const appliedLaunch: AppliedLaunchObservation = envelope ? observeCodexEnvelope(envelope) : observeCodexSandbox(posture);
|
||||
// The seat's envelope is not full access, so a policy's full_bypass extras don't follow it.
|
||||
const postureArg = posture + operationalLaunchArg(this.runtime, memberChoice ? { ...binding, kernelAuthority: false, launchPosture: undefined } : binding);
|
||||
const postureArg = posture + operationalLaunchArg(this.runtime, memberEnvelope ? { ...binding, kernelAuthority: false, launchPosture: undefined } : binding);
|
||||
|
||||
if (running) {
|
||||
// The running Codex already loaded its profile; drift in -p is the caller's to compare.
|
||||
@@ -1161,10 +1114,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// conversation id, as on every resume, so the resume proof reads the id even when a dir has a space.
|
||||
return { ok: true, model, modelArg, effort, effortArg, profile, appliedLaunch, postureArg,
|
||||
queueStateDirArg: "",
|
||||
gitDirArg: (filesystemPermissions ? [] : running.addDirs).map((dir) => ` --add-dir ${shellQuote(dir)}`).join(""),
|
||||
gitDirArg: running.addDirs.map((dir) => ` --add-dir ${shellQuote(dir)}`).join(""),
|
||||
seatFolderArg: "",
|
||||
daemonOptOut: running.daemonOptOut, daemonArg: running.daemonOptOut ? " --no-daemon" : "",
|
||||
networkArg: !filesystemPermissions && running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "",
|
||||
networkArg: running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "",
|
||||
loginShellArg: running.nonLoginShell ? codexNonLoginShellArg : "" };
|
||||
}
|
||||
|
||||
@@ -1172,14 +1125,19 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// [profiles.<name>] table or invalid TOML must fail BEFORE the opaque
|
||||
// `codex -p <profile> resume` failure. An absent .config.toml passes
|
||||
// (Codex default-layers it; advisor Option B).
|
||||
if (profile && !checkedBeforeCutover) {
|
||||
const loaded = await this.checkProfileLoad(binding);
|
||||
if (!loaded.ok) return loaded;
|
||||
if (profile) {
|
||||
const probeResult = await (own ? this.verifyProfilePreflight(profile, binding.cwd, own) : this.verifyProfilePreflight(profile, binding.cwd));
|
||||
if (!probeResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${probeResult.error}${probeResult.migrationHint ? `\n Fix: ${probeResult.migrationHint}` : ""}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Codex rejects writable --add-dir roots under a read-only sandbox. Only
|
||||
// suppress OpenRig's generated roots when later project layers cannot make it writable.
|
||||
// A seat envelope states its sandbox: read-only gets no generated roots; workspace-write keeps them.
|
||||
const suppressAutoAddDirs = memberChoice ? memberChoice.suppressAutoAddDirs
|
||||
const suppressAutoAddDirs = memberEnvelope ? memberEnvelope.sandboxPolicy.type === "read-only"
|
||||
: Boolean(!binding.codexRebuildPermissions && profile && posture === profileArg && this.profileRemainsReadOnly(profile, binding.cwd, home));
|
||||
// Issue #121: fresh, resume and fork get the same git metadata dirs, so a restored or forked seat can still commit.
|
||||
// A rebuild keeps the writable roots its observed envelope names instead.
|
||||
@@ -1188,7 +1146,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// The seat's own topology folder, so it can keep its LEARNED.md and recap: the same launches and the same suppression.
|
||||
const seatFolder = binding.codexRebuildPermissions || suppressAutoAddDirs ? undefined : this.prepareSeatFolder?.(name);
|
||||
const seatFolderArg = seatFolder ? ` --add-dir ${shellQuote(seatFolder)}` : "";
|
||||
const teamWorkspaceArg = !memberChoice && !envelope && binding.teamPermissionDefault && !profile
|
||||
const teamWorkspaceArg = !envelope && binding.teamPermissionDefault && !profile
|
||||
&& appliedLaunch.state === "observed" && appliedLaunch.value === "workspace-write"
|
||||
? codexTeamWorkspaceArg(this.prepareTeamWorkspace, name) : "";
|
||||
const queueStateDirArg = binding.codexRebuildPermissions || suppressAutoAddDirs
|
||||
@@ -1202,7 +1160,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const daemonOptOut = daemonSupport?.kind === "supported";
|
||||
const daemonArg = daemonOptOut ? " --no-daemon" : "";
|
||||
// #275: on the plain floor, network access unless Codex reports an opt-out or policy. An envelope states its own.
|
||||
const networkArg = memberChoice || envelope ? ""
|
||||
const networkArg = envelope ? ""
|
||||
: await codexNetworkDefaultArg(own && this.readNetworkDefault ? ((cwd: string) => this.readNetworkDefault!(cwd, own)) : this.readNetworkDefault, appliedLaunch, binding.cwd, name);
|
||||
|
||||
// The launch facts keep the probed daemon choice, so a printed recovery command can repeat it; no probe, no fact.
|
||||
|
||||
@@ -63,8 +63,6 @@ export const POST_COMPACT_RESTORE_COOLDOWN_MS_DEFAULT = 10 * 60_000;
|
||||
// writing the restore map can take a minute+; the wait returns as soon as the
|
||||
// seat is idle, so this only bounds a pathological never-idle case.
|
||||
export const MANUAL_PREP_WAIT_MS_DEFAULT = 180_000;
|
||||
/** How often a manual attempt reads its seat while it waits (for the map, or after a /compact the seat wasn't idle for). */
|
||||
export const MANUAL_SEAT_READ_INTERVAL_MS = 5_000;
|
||||
// Operational ceiling, not a measured optimum. Existing context polls advance auto preparation.
|
||||
export const AUTO_PREP_WAIT_MS_DEFAULT = 25 * 60_000;
|
||||
// How long a stopped attempt holds automatic compaction off for its seat before a fresh attempt may begin. Without it one
|
||||
@@ -226,7 +224,7 @@ export function buildCompactCommand(compactInstruction: string): string {
|
||||
}
|
||||
|
||||
function buildPreCompactPrepPrompt(input: {
|
||||
usedPercentage: number | null;
|
||||
usedPercentage: number;
|
||||
thresholdPercent: number;
|
||||
preCompactInstruction?: string | null;
|
||||
/** The policy's compaction instruction when it is too long for the /compact command. */
|
||||
@@ -241,7 +239,7 @@ function buildPreCompactPrepPrompt(input: {
|
||||
]
|
||||
: [
|
||||
"OpenRig automatic compaction preparation is now required.",
|
||||
`Current context usage is ${input.usedPercentage ?? "unknown"}%; configured compaction threshold is ${input.thresholdPercent}%.`,
|
||||
`Current context usage is ${input.usedPercentage}%; configured compaction threshold is ${input.thresholdPercent}%.`,
|
||||
`The ${AUTO_PREP_WAIT_MS_DEFAULT / 60_000}-minute ceiling starts after preparation delivery returns; completing the restore map and becoming idle must fit within it.`,
|
||||
];
|
||||
pieces.push(
|
||||
@@ -255,7 +253,6 @@ function buildPreCompactPrepPrompt(input: {
|
||||
pieces.push(
|
||||
`Write this attempt's complete restore map to ${JSON.stringify(input.preparation.mapPath + ".tmp")}, using your ordinary file-edit tool. Append the exact completion marker below as its LAST line, then finish and close the file. OpenRig will atomically publish it to ${JSON.stringify(input.preparation.mapPath)}; do not run a shell command to rename or publish it. Never write the final file incrementally.`,
|
||||
`Completion marker: ${input.preparation.marker}`,
|
||||
"To avoid one cause of approval prompts, which hold /compact until someone answers them: write files with your file-edit tool (acceptEdits covers your working directory, not a preparation root outside it), and if you publish a seat recap, run rig context recap-write --file with the file's literal absolute path, with no shell variable assignment or command substitution, so an allow rule for rig commands can match it.",
|
||||
"The marker identifies this attempt and occupant; it does not certify the map's quality. Keep the normal ranked restore-map content.",
|
||||
`Near the top of the map, copy this note word for word; the restore request will have you read it: ${MANAGED_COMPACT_TRUST_NOTE}`,
|
||||
...(input.overflowCompactInstruction?.trim()
|
||||
@@ -501,10 +498,6 @@ export interface PreparationState {
|
||||
mapUsedPercentage?: number;
|
||||
/** A record too old to resume that this attempt replaced (see `getStrandedCompaction`): its restore may have been owed. */
|
||||
supersedes?: StrandedCompaction;
|
||||
/** A manual attempt's latest reading of its seat showed a prompt (a permission request its preparation turn reached,
|
||||
* say) that holds /compact: since when, and when it was last seen. `current` is false when a later reading couldn't
|
||||
* establish the seat's state, so the prompt is an earlier observation. Cleared when a reading shows idle or work. */
|
||||
awaitingInput?: { since: number; reason: string; observedAt: number; current: boolean };
|
||||
}
|
||||
/** An open record from before a restart, too old to resume: the seat's restore steps may still be owed. */
|
||||
export interface StrandedCompaction { attemptId: string; mapPath: string; phase: string; recordedAt: string }
|
||||
@@ -1416,6 +1409,7 @@ export class ClaudeCompactionEnforcer {
|
||||
return { triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" };
|
||||
}
|
||||
if (input.runtime !== "claude-code") return this.recordManualFailure(input.sessionName, "runtime_filter");
|
||||
if (input.usedPercentage == null) return this.recordManualFailure(input.sessionName, "no_usage_data");
|
||||
// The preparation prompt cannot be written to such a seat either, so the whole attempt is refused up front.
|
||||
if (this.paneUnknownProtected(input.sessionName)) return this.recordManualFailure(input.sessionName, "pane_unknown_protected");
|
||||
const actor = opts.actor ?? { source: "caller" as const, session: null };
|
||||
@@ -1435,69 +1429,34 @@ export class ClaudeCompactionEnforcer {
|
||||
};
|
||||
this.setManualStage(input.sessionName, "preparing", undefined, opts.operatorInitiated === true);
|
||||
await this.deliverPreparation(input, attempt);
|
||||
// Manual preparation can pause for an ordinary approval, before or after the map is complete. OpenRig never answers
|
||||
// it. It keeps the latest completed reading of the seat on the attempt (awaitingInput), recorded before deadline
|
||||
// reconciliation, so --state and the expiry reason describe the seat now rather than an earlier prompt.
|
||||
const observe = (reading: { ok: boolean; reason?: string; activity?: { state?: string } } | null) => {
|
||||
const state = reading === null ? "unknown" : reading.ok ? "idle"
|
||||
: reading.reason === "target_needs_input" ? "needs_input" : reading.activity?.state ?? "unknown";
|
||||
if (state === "needs_input") {
|
||||
attempt.awaitingInput = { since: attempt.awaitingInput?.since ?? this.now(), reason: "target_needs_input", observedAt: this.now(), current: true };
|
||||
} else if (state === "unknown") {
|
||||
if (attempt.awaitingInput) attempt.awaitingInput = { ...attempt.awaitingInput, current: false };
|
||||
} else delete attempt.awaitingInput;
|
||||
};
|
||||
// While the map is written, one bounded reading every MANUAL_SEAT_READ_INTERVAL_MS, never one per map check. The first
|
||||
// comes an interval after delivery: a reading taken as the preparation turn starts tells nothing.
|
||||
let nextRead = this.now() + MANUAL_SEAT_READ_INTERVAL_MS;
|
||||
while (attempt.status === "waiting" && !opts.skipMap && !this.mapReady(attempt)) {
|
||||
const remaining = attempt.deadlineAt! - this.now();
|
||||
if (remaining > 0 && this.now() >= nextRead) {
|
||||
let reading;
|
||||
try { reading = await this.sessionTransport.waitUntilIdle(input.sessionName, Math.min(remaining, CAPTURE_READABLE_BUDGET_MS), attempt.controller.signal, { singleObservation: true, allowDraft: true }); }
|
||||
catch { reading = null; }
|
||||
observe(reading);
|
||||
nextRead = this.now() + MANUAL_SEAT_READ_INTERVAL_MS;
|
||||
}
|
||||
this.reconcilePreparations();
|
||||
if (attempt.status !== "waiting") break;
|
||||
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
}
|
||||
if (attempt.status !== "waiting") return failed(attempt.reason ?? "preparation_incomplete");
|
||||
// The budget ran out: name a prompt the latest reading showed, else as before.
|
||||
const expired = () => attempt.awaitingInput?.current ? "preparation_needs_input"
|
||||
: this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete";
|
||||
let compact: { ok: boolean; reason?: string; deferredBy?: { reason?: string; activity?: { state?: string } } };
|
||||
for (;;) {
|
||||
let idle;
|
||||
do {
|
||||
const remaining = attempt.deadlineAt! - this.now();
|
||||
if (remaining <= 0) return failed(expired());
|
||||
// The wait can run to the deadline. Each reading it completes reaches the attempt as it lands, so a --state read or
|
||||
// deadline reconciliation during the wait never takes an answered prompt for a current one.
|
||||
try { idle = await this.sessionTransport.waitUntilIdle(input.sessionName, remaining, attempt.controller.signal,
|
||||
{ allowDraft: true, onObservation: (activity) => observe({ ok: false, activity }) }); }
|
||||
catch { idle = { ok: false, reason: "transport_unavailable" }; }
|
||||
observe(idle);
|
||||
this.reconcilePreparations();
|
||||
if (idle.ok || idle.reason !== "target_needs_input" || attempt.status !== "waiting") break;
|
||||
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
this.reconcilePreparations();
|
||||
} while (attempt.status === "waiting");
|
||||
if (!idle.ok || attempt.status !== "waiting") {
|
||||
// The transport was given this attempt's remaining budget; name its timeout like preparation expiry.
|
||||
const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete"
|
||||
: idle.reason === "wait_for_idle_timeout" ? expired() : idle.reason);
|
||||
return failed(reason);
|
||||
}
|
||||
compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true);
|
||||
// The seat wasn't idle at the send after all (its preparation turn reached a prompt, or did more work): nothing was
|
||||
// written, so wait again within this attempt's deadline rather than disarm. The send reads the pane while the idle
|
||||
// wait can take a hook's idle, so pause one reading interval first: a disagreement can't loop sends without a break.
|
||||
if (compact.ok || compact.reason !== "waiting_for_idle") break;
|
||||
observe({ ok: false, ...compact.deferredBy });
|
||||
await this.sleep(Math.min(MANUAL_SEAT_READ_INTERVAL_MS, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
// Manual preparation can pause for an ordinary approval after the map is complete.
|
||||
// Keep the same classifier/deadline and wait outside the input lease; never answer it.
|
||||
let idle;
|
||||
do {
|
||||
const remaining = attempt.deadlineAt! - this.now();
|
||||
if (remaining <= 0) return failed(this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete");
|
||||
try { idle = await this.sessionTransport.waitUntilIdle(input.sessionName, remaining, attempt.controller.signal, { allowDraft: true }); }
|
||||
catch { idle = { ok: false, reason: "transport_unavailable" }; }
|
||||
this.reconcilePreparations();
|
||||
if (idle.ok || idle.reason !== "target_needs_input" || attempt.status !== "waiting") break;
|
||||
await this.sleep(Math.min(250, Math.max(1, attempt.deadlineAt! - this.now())));
|
||||
this.reconcilePreparations();
|
||||
} while (attempt.status === "waiting");
|
||||
if (!idle.ok || attempt.status !== "waiting") {
|
||||
// The transport was given this attempt's remaining budget; name its timeout like preparation expiry.
|
||||
const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete"
|
||||
: idle.reason === "wait_for_idle_timeout"
|
||||
? this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete"
|
||||
: idle.reason);
|
||||
return failed(reason);
|
||||
}
|
||||
const compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true);
|
||||
if (!compact.ok) {
|
||||
const outcome = failed(compact.reason ?? "send_failed");
|
||||
// A /compact whose result was lost still owes its restore steps, and this request authorizes them.
|
||||
@@ -1631,7 +1590,7 @@ export class ClaudeCompactionEnforcer {
|
||||
const composed = composeManagedCompactCommand({ compactInstruction: attempt.compactInstruction, mapPath: attempt.mapPath });
|
||||
prep = await this.sessionTransport.send(input.sessionName, buildPreCompactPrepPrompt({
|
||||
overflowCompactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
|
||||
usedPercentage: input.usedPercentage ?? null, thresholdPercent: policy.thresholdPercent,
|
||||
usedPercentage: input.usedPercentage!, thresholdPercent: policy.thresholdPercent,
|
||||
preCompactInstruction: policy.preCompactInstruction, preparation: attempt, constructedAt: this.now(),
|
||||
}), { openrigNotice: true, allowDraftWhileIdle: true, ...idleGate, beforeWrite: () => {
|
||||
this.reconcilePreparations();
|
||||
@@ -1675,7 +1634,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.reconcilePreparations();
|
||||
}
|
||||
|
||||
private async sendPreparedCompact(input: EnforcerInput, attempt: PreparationAttempt, skipMap: boolean): Promise<{ok: boolean; reason?: string; deferredBy?: { reason?: string; activity?: { state?: string } }}> {
|
||||
private async sendPreparedCompact(input: EnforcerInput, attempt: PreparationAttempt, skipMap: boolean): Promise<{ok: boolean; reason?: string}> {
|
||||
const check = () => {
|
||||
this.reconcilePreparations();
|
||||
this.checkAttemptPolicy(input, attempt);
|
||||
@@ -1746,15 +1705,13 @@ export class ClaudeCompactionEnforcer {
|
||||
if (notSent && typeof windowReason === "string" && HOLD_REASONS.has(windowReason) && this.holdPrepared(input.sessionName, attempt, windowReason)) {
|
||||
return { ok: false, reason: PREPARED_AWAITING_WINDOW };
|
||||
}
|
||||
// Only a known pre-input idle deferral keeps the attempt armed: an automatic one for a later poll, a manual one for
|
||||
// the rest of its deadline (its caller waits again). The transport marks those `not_reached`. Reconciliation still
|
||||
// bounds the idle grace and stops on an occupant change.
|
||||
// Only a known pre-input idle deferral keeps the automatic attempt armed for a later poll; the transport
|
||||
// marks those `not_reached`. Reconciliation still bounds the idle grace and stops on an occupant change.
|
||||
// A typing-guard draft hold (`draft_*`) is `not_reached` too, but it is a refusal, not an idle wait: it stops
|
||||
// as not sent, and a fresh attempt may begin after the retry cooldown.
|
||||
const draftHold = policyDraftHold || (typeof result.reason === "string" && result.reason.startsWith("draft_"));
|
||||
if (attempt.status === "waiting" && notSent && phase === "not_reached" && !draftHold
|
||||
&& (attempt.mode === "automatic" || this.now() < attempt.deadlineAt!)) {
|
||||
return { ok: false, reason: "waiting_for_idle", deferredBy: { reason: result.reason, activity: result.activity } };
|
||||
if (attempt.mode === "automatic" && attempt.status === "waiting" && notSent && phase === "not_reached" && !draftHold) {
|
||||
return { ok: false, reason: "waiting_for_idle" };
|
||||
}
|
||||
// A compact may have been sent despite a lost result. Never replay automatically.
|
||||
attempt.compact = notSent ? "not_sent" : "uncertain";
|
||||
@@ -1833,9 +1790,7 @@ export class ClaudeCompactionEnforcer {
|
||||
else if (!attempt.prepared && attempt.deadlineAt !== null && this.now() >= this.compactDeadline(attempt)!) {
|
||||
const ready = this.mapReady(attempt);
|
||||
if (!(ready && this.holdPrepared(session, attempt, "map_complete_seat_never_idle"))) {
|
||||
// A manual attempt whose latest reading is a prompt says so (only the manual wait records awaitingInput).
|
||||
this.stopPreparation(session, attempt.awaitingInput?.current ? "preparation_needs_input"
|
||||
: ready ? "map_complete_seat_never_idle" : "preparation_incomplete");
|
||||
this.stopPreparation(session, ready ? "map_complete_seat_never_idle" : "preparation_incomplete");
|
||||
}
|
||||
}
|
||||
if (enabled) attempt.policyWasEnabled = true;
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import {
|
||||
CODEX_SEAT_PERMISSION_PROFILE,
|
||||
codexFilesystemOverrides,
|
||||
type CodexFilesystemAccess,
|
||||
type CodexFilesystemPermissions,
|
||||
} from "./codex-member-permissions.js";
|
||||
import { readCodexConfiguration } from "./codex-network-default.js";
|
||||
|
||||
export type CodexFilesystemDecision =
|
||||
| { ok: true; verified: true }
|
||||
| { ok: true; verified: false; reason: string }
|
||||
| { ok: false; error: string };
|
||||
const verified = (): CodexFilesystemDecision => ({ ok: true, verified: true });
|
||||
const refuse = (error: string, remedy: string): CodexFilesystemDecision => ({ ok: false, error: `${error}\nFix: ${remedy}` });
|
||||
const unknown = (reason: string): CodexFilesystemDecision => ({ ok: true, verified: false, reason });
|
||||
export const codexPolicyWarning = (reason: string): string => `policy not verified: ${reason}`;
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
// Null means absent, including options added by a later native reader.
|
||||
const present = (record: Record<string, unknown>) => Object.entries(record).filter(([, value]) => value != null);
|
||||
function combine(...checks: CodexFilesystemDecision[]): CodexFilesystemDecision {
|
||||
let inconclusive: Extract<CodexFilesystemDecision, { verified: false }> | undefined;
|
||||
for (const check of checks) {
|
||||
if (!check.ok) return check;
|
||||
if (!check.verified) inconclusive ??= check;
|
||||
}
|
||||
return inconclusive ?? verified();
|
||||
}
|
||||
const accessRank = { deny: 0, read: 1, write: 2 };
|
||||
const isAccess = (value: unknown): value is CodexFilesystemAccess => value === "read" || value === "write" || value === "deny";
|
||||
const canonicalKey = (key: string) => key === ":project_roots" ? ":workspace_roots" : key;
|
||||
function ambiguousKeys(keys: string[]): Set<string> {
|
||||
const seen = new Set<string>(), ambiguous = new Set<string>();
|
||||
for (const key of keys.map(canonicalKey)) {
|
||||
if (seen.has(key)) ambiguous.add(key);
|
||||
seen.add(key);
|
||||
}
|
||||
return ambiguous;
|
||||
}
|
||||
const literalAbsolute = (key: string) => path.isAbsolute(key) && path.normalize(key) === key && !/[?*\[\]{}]/.test(key);
|
||||
const nativeSelectors = new Set([":root", ":minimal", ":workspace_roots", ":tmpdir", ":slash_tmp"]);
|
||||
const literalHomePath = (key: string) => key.startsWith("~/") && path.normalize(key) === key && !/[?*\[\]{}]/.test(key);
|
||||
|
||||
/** Compare only established flat coverage. Native expansion, inheritance and
|
||||
* unfamiliar syntax stay unknown; this is not a second sandbox implementation. */
|
||||
function compareFilesystem(actual: unknown, permissions: CodexFilesystemPermissions): CodexFilesystemDecision {
|
||||
if (actual == null) return refuse("Rule permissions.openrig-seat.filesystem: the agent declares read/write/deny rules, but the effective Codex profile has no mapping.",
|
||||
"Select a Codex profile that retains the agent's filesystem rules, or deliberately change the agent's codex_permissions.");
|
||||
if (!isRecord(actual)) return unknown("Codex selected filesystem mapping has an unfamiliar representation");
|
||||
const entries = present(actual);
|
||||
if (!entries.length) return refuse("Rule permissions.openrig-seat.filesystem: the agent declares read/write/deny rules, but the effective Codex mapping is empty.",
|
||||
"Restore the intended filesystem mapping in the effective Codex profile before launching.");
|
||||
const authored = Object.fromEntries(Object.entries(permissions.filesystem).map(([key, value]) => [canonicalKey(key), value]));
|
||||
const effective = Object.fromEntries(entries.map(([key, value]) => [canonicalKey(key), value]));
|
||||
const authoredAmbiguous = ambiguousKeys(Object.keys(permissions.filesystem));
|
||||
const ambiguous = new Set([...authoredAmbiguous, ...ambiguousKeys(entries.map(([key]) => key))]);
|
||||
const at = (rules: Record<string, unknown>, key: string, extraAccess?: CodexFilesystemAccess): CodexFilesystemAccess | undefined => {
|
||||
if (ambiguous.has(key)) return undefined;
|
||||
if (isAccess(rules[key])) return rules[key];
|
||||
const fullRoot = rules[":root"] ?? (literalAbsolute(path.sep) ? rules[path.sep] : undefined);
|
||||
const rootAccess = isAccess(fullRoot) ? fullRoot : "deny";
|
||||
const ancestors = Object.keys(rules).filter(root => (literalAbsolute(root) || literalHomePath(root)) && key.startsWith(root.endsWith(path.sep) ? root : root + path.sep))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
const root = ancestors[0];
|
||||
const ancestorAccess = root ? rules[root] : rootAccess;
|
||||
// Native selectors/home paths can overlap authored literal paths or other
|
||||
// selectors. Do not infer a widening where an existing grant may cover it;
|
||||
// native expansion remains Codex's job, not another policy engine here.
|
||||
// Only the agent's own ambiguity can affect coverage of another key;
|
||||
// duplicate aliases introduced by a lower layer stay local to those aliases.
|
||||
// Non-root denies are restrictions; either full-root spelling is a
|
||||
// default that narrower grants may refine under Codex's native rules.
|
||||
const denyAncestor = root !== undefined && root !== path.sep && rules[root] === "deny";
|
||||
// Codex 0.167.2's Linux/macOS minimal defaults are system paths, not
|
||||
// these user-data roots. Other minimal coverage stays unexpanded.
|
||||
const outsideMinimal = literalAbsolute(key) && /^\/(?:home|Users)(?:\/|$)/i.test(key);
|
||||
const possibleCoverage = extraAccess !== undefined && !denyAncestor
|
||||
&& (!isAccess(ancestorAccess) || accessRank[extraAccess] > accessRank[ancestorAccess])
|
||||
&& (authoredAmbiguous.size > 0 || Object.entries(rules).some(([rule, access]) => rule !== ":root" && rule !== path.sep
|
||||
&& !(rule === ":minimal" && outsideMinimal)
|
||||
&& (nativeSelectors.has(key) || literalHomePath(key) || !literalAbsolute(rule))
|
||||
&& isAccess(access) && accessRank[access] >= accessRank[extraAccess]));
|
||||
if (nativeSelectors.has(key)) return key !== ":root" && possibleCoverage ? undefined : rootAccess;
|
||||
if (!literalAbsolute(key) && !literalHomePath(key)) return undefined;
|
||||
if (key !== path.sep && possibleCoverage) return undefined;
|
||||
if (root) return isAccess(rules[root]) ? rules[root] : undefined;
|
||||
if (isAccess(fullRoot)) return fullRoot;
|
||||
if (literalHomePath(key)) return "deny";
|
||||
if (Object.keys(rules).some(rule => !literalAbsolute(rule) && rule !== ":minimal")) return undefined;
|
||||
// :minimal read cannot grant write; its native read coverage is not
|
||||
// enumerated here, so extra reads outside a literal ancestor stay unknown.
|
||||
if (rules[":minimal"] != null && !outsideMinimal) return rules[":minimal"] === "read" ? "read" : undefined;
|
||||
return "deny";
|
||||
};
|
||||
const checks: CodexFilesystemDecision[] = [];
|
||||
const unfamiliar = entries.some(([, value]) => !isAccess(value));
|
||||
for (const [key, value] of Object.entries(effective)) {
|
||||
if (!isAccess(value)) { checks.push(unknown("Codex returned an unfamiliar filesystem access value or nested mapping")); continue; }
|
||||
const expected = at(authored, key, value);
|
||||
if (expected === undefined) { checks.push(unknown(`Filesystem rule ${JSON.stringify(key)}: the effective configuration grants ${value}; agent coverage could not be compared.`)); continue; }
|
||||
if (accessRank[value] > accessRank[expected]) return refuse(
|
||||
`Filesystem rule ${JSON.stringify(key)}: agent coverage is at most ${expected}; a lower Codex layer (account or trusted project) grants ${value}.`,
|
||||
"Remove the conflicting lower-layer grant, or deliberately update the agent's codex_permissions to the intended access.");
|
||||
if (!(key in authored) && value === "read" && expected === "read" && !Object.keys(authored).some(root =>
|
||||
root === ":root" || (literalAbsolute(root) && literalAbsolute(key) && key.startsWith(root.endsWith(path.sep) ? root : root + path.sep)))) {
|
||||
checks.push(unknown(`Filesystem rule ${JSON.stringify(key)}: native minimal read coverage could not be compared.`));
|
||||
}
|
||||
}
|
||||
for (const [key, access] of Object.entries(authored)) {
|
||||
if (key in effective) continue;
|
||||
const remaining = at(effective, key);
|
||||
if (remaining !== undefined && accessRank[remaining] > accessRank[access] && !unfamiliar) return refuse(
|
||||
`Filesystem rule ${JSON.stringify(key)}: the agent requires ${access}; the effective Codex policy omits that restriction and grants ${remaining}.`,
|
||||
"Restore the agent's restriction in the effective Codex profile, or deliberately change the agent's codex_permissions.");
|
||||
checks.push(unknown("An authored filesystem rule is absent from the effective mapping"));
|
||||
}
|
||||
return combine(...checks);
|
||||
}
|
||||
|
||||
const UNRELATED_REQUIREMENTS = new Set([
|
||||
"modelProvider", "modelProviders", "allowedLoginMethods", "cliAuthCredentialsStore", "chatgptBaseUrl",
|
||||
"additionalDeveloperInstructions", "allowedWindowsSandboxImplementations", "allowedWebSearchModes",
|
||||
"allowManagedHooksOnly", "allowBrowserAndComputerUse", "allowAppshots", "allowRemoteControl",
|
||||
"computerUse", "browserUse", "inAppBrowser", "hooks", "enforceResidency", "models", "sqliteHome",
|
||||
"logDir", "modelCatalogJson", "checkForUpdateOnStartup", "allowLoginShell", "feedback", "otel",
|
||||
// Legacy sandbox allowlists do not select a permission profile.
|
||||
"allowedSandboxModes",
|
||||
]);
|
||||
function compareRequirements(read: unknown, permissions: CodexFilesystemPermissions): CodexFilesystemDecision {
|
||||
if (!isRecord(read) || !("requirements" in read)) return unknown("Codex configRequirements/read returned an unrecognized response");
|
||||
const checks: CodexFilesystemDecision[] = [];
|
||||
if (present(read).some(([key]) => key !== "requirements" && key !== "supportsIndependentSpeedModes")) checks.push(unknown("Codex returned an unfamiliar requirements response field"));
|
||||
if (read.requirements == null) return combine(...checks);
|
||||
if (!isRecord(read.requirements)) return unknown("Codex managed requirements have an unfamiliar shape");
|
||||
for (const [key, value] of present(read.requirements)) {
|
||||
if (UNRELATED_REQUIREMENTS.has(key)) continue;
|
||||
if (key === "defaultPermissions" && typeof value === "string") {
|
||||
// A fallback, not a forced choice. The effective selection and allowlists
|
||||
// below establish whether Codex actually applied the agent's profile.
|
||||
continue;
|
||||
}
|
||||
if (key === "allowedApprovalPolicies" && Array.isArray(value) && value.every(policy => typeof policy === "string")) {
|
||||
if (!value.includes(permissions.approval)) return refuse("Rule allowedApprovalPolicies: the managed allowlist excludes the agent's approval choice.",
|
||||
"Ask the managed-policy owner to allow the intended approval, or select an allowed approval for the agent.");
|
||||
continue;
|
||||
}
|
||||
if (key === "allowedPermissionProfiles" && isRecord(value)) {
|
||||
if (value[CODEX_SEAT_PERMISSION_PROFILE] === false) return refuse("Rule allowedPermissionProfiles: the managed allowlist explicitly excludes the agent's filesystem profile.",
|
||||
"Ask the managed-policy owner to allow the agent's filesystem profile, or select an allowed policy for the agent.");
|
||||
const entries = present(value);
|
||||
if (entries.every(([, allowed]) => typeof allowed === "boolean")) {
|
||||
if (value[CODEX_SEAT_PERMISSION_PROFILE] !== true) return refuse("Rule allowedPermissionProfiles: the managed allowlist does not include the agent's filesystem profile.",
|
||||
"Ask the managed-policy owner to allow the agent's filesystem profile, or select an allowed policy for the agent.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
checks.push(unknown("Codex returned an unsupported managed requirement"));
|
||||
}
|
||||
return combine(...checks);
|
||||
}
|
||||
|
||||
/** Positive conflicts refuse; unavailable evidence never replaces the authored
|
||||
* invocation or edits managed policy. Unknowns cannot mask a known conflict. */
|
||||
export function decideCodexFilesystemPolicy(request: {
|
||||
permissions: CodexFilesystemPermissions; configRead: unknown; requirementsRead: unknown;
|
||||
}): CodexFilesystemDecision {
|
||||
const { permissions, configRead, requirementsRead } = request;
|
||||
const requirements = compareRequirements(requirementsRead, permissions);
|
||||
if (!requirements.ok) return requirements;
|
||||
const checks: CodexFilesystemDecision[] = [requirements];
|
||||
if (!isRecord(configRead) || !isRecord(configRead.config)) return combine(...checks, unknown("Codex config/read did not return a recognized effective configuration"));
|
||||
const config = configRead.config;
|
||||
if (config.default_permissions == null || typeof config.default_permissions !== "string") checks.push(unknown("Codex permission selection is absent or has an unfamiliar representation"));
|
||||
else if (config.default_permissions !== CODEX_SEAT_PERMISSION_PROFILE) return refuse("Rule default_permissions: the agent selects its filesystem profile; the effective Codex configuration selects a different profile.",
|
||||
"Remove the conflicting account or trusted-project selection, or deliberately change the agent's codex_permissions.");
|
||||
if (typeof config.approval_policy !== "string" || !["never", "on-request", "untrusted"].includes(config.approval_policy)) checks.push(unknown("Codex approval selection is absent or unfamiliar"));
|
||||
else if (config.approval_policy !== permissions.approval) return refuse("Rule approval_policy: the effective Codex configuration conflicts with the agent's approval choice.",
|
||||
"Align the account, trusted-project or managed approval policy with the agent's intended approval choice.");
|
||||
if (config.permissions == null) return refuse("Rule permissions.openrig-seat: the agent selects a filesystem profile, but the effective Codex configuration has no profile mapping.",
|
||||
"Restore the selected profile in the effective Codex configuration before launching.");
|
||||
if (!isRecord(config.permissions)) return combine(...checks, unknown("Codex permission profiles have an unfamiliar representation"));
|
||||
const selected = config.permissions[CODEX_SEAT_PERMISSION_PROFILE];
|
||||
if (selected == null) return refuse("Rule permissions.openrig-seat: the agent selects a filesystem profile that is absent from the effective Codex configuration.",
|
||||
"Restore the selected profile in the effective Codex configuration before launching.");
|
||||
if (!isRecord(selected)) return combine(...checks, unknown("Codex selected profile has an unfamiliar representation"));
|
||||
if (!present(selected).length) return refuse("Rule permissions.openrig-seat: the agent selects filesystem read/write/deny rules, but the effective Codex profile is empty.",
|
||||
"Restore the selected profile's intended filesystem rules before launching.");
|
||||
const unfamiliarProfile = present(selected).some(([key]) => key !== "filesystem" && key !== "network" && key !== "description");
|
||||
if (unfamiliarProfile) checks.push(unknown("Codex returned an unfamiliar selected profile option"));
|
||||
if (!(unfamiliarProfile && selected.filesystem == null)) checks.push(compareFilesystem(selected.filesystem, permissions));
|
||||
const network = selected.network;
|
||||
if (!isRecord(network)) checks.push(unknown("Codex selected network policy could not be compared"));
|
||||
else {
|
||||
if (network.enabled === true && !permissions.network_access) return refuse("Rule network.enabled: the agent denies network access; the effective Codex profile allows it.",
|
||||
"Remove the conflicting lower-layer network grant, or deliberately enable network_access in the agent's codex_permissions.");
|
||||
if (typeof network.enabled !== "boolean") checks.push(unknown("Codex selected network access is unfamiliar"));
|
||||
if (present(network).some(([key]) => key !== "enabled")) checks.push(unknown("Codex returned unfamiliar network options"));
|
||||
}
|
||||
return combine(...checks);
|
||||
}
|
||||
|
||||
/** Pin the normal account home for this opt-in path so the reader and dispatch
|
||||
* do not accidentally use different pane-shell defaults. */
|
||||
export function resolveCodexFilesystemHome(home?: string, normalHome?: string): string | undefined {
|
||||
try {
|
||||
const resolved = home ?? process.env.CODEX_HOME ?? path.join(normalHome ?? os.homedir(), ".codex");
|
||||
return resolved && path.isAbsolute(resolved) && !/[\0\r\n]/.test(resolved) ? resolved : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
export async function verifyCodexFilesystemPolicy(request: {
|
||||
permissions: CodexFilesystemPermissions; cwd: string; codexHome?: string;
|
||||
launchPath?: string; launchWrapper?: readonly string[]; codexConfigProfile?: string;
|
||||
}): Promise<CodexFilesystemDecision> {
|
||||
if (request.launchWrapper?.length) return unknown("launch_wrapper may change the effective home or configuration; the unwrapped reader cannot certify it");
|
||||
// app-server does not expose this CLI profile layer on the supported path.
|
||||
// Keep -p on the actual invocation and leave enforcement to Codex.
|
||||
if (request.codexConfigProfile?.trim()) return unknown("the native reader cannot load the selected codex_config_profile");
|
||||
const codexHome = resolveCodexFilesystemHome(request.codexHome);
|
||||
if (!codexHome) return unknown("the actual Codex account home could not be resolved");
|
||||
try {
|
||||
const read = await readCodexConfiguration({ cwd: request.cwd, codexHome, launchPath: request.launchPath,
|
||||
args: ["-c", `approval_policy=${JSON.stringify(request.permissions.approval)}`,
|
||||
...codexFilesystemOverrides(request.permissions).flatMap(value => ["-c", value]),
|
||||
"-c", "features.plugins=false", "-c", "features.apps=false"] });
|
||||
if (!read.ok) return unknown(read.reason);
|
||||
return decideCodexFilesystemPolicy({ permissions: request.permissions, configRead: read.configRead, requirementsRead: read.requirementsRead });
|
||||
} catch { return unknown("Native Codex configuration reader failed"); }
|
||||
}
|
||||
|
||||
export type CodexFilesystemVerifier = typeof verifyCodexFilesystemPolicy;
|
||||
@@ -4,14 +4,12 @@ import { constants as fsConstants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { CodexPermissionEnvelope } from "./codex-rebuild-permissions.js";
|
||||
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { AppliedLaunchObservation } from "./permission-drift.js";
|
||||
|
||||
/** A Codex seat's own sandbox and approval choice, emitted as explicit flags on its managed launches and resumes.
|
||||
* Full access is not an envelope: it stays the full_bypass mode. Normalized, a workspace-write envelope states every
|
||||
* field the emitter sets, so nothing falls back to a profile's value for them. */
|
||||
export interface CodexSandboxPermissions {
|
||||
export interface CodexMemberPermissions {
|
||||
sandbox: "workspace-write" | "read-only";
|
||||
approval: "never" | "on-request" | "untrusted";
|
||||
network_access?: boolean;
|
||||
@@ -20,60 +18,20 @@ export interface CodexSandboxPermissions {
|
||||
exclude_slash_tmp?: boolean;
|
||||
}
|
||||
|
||||
export type CodexFilesystemAccess = "read" | "write" | "deny";
|
||||
|
||||
/** An explicit native filesystem policy, mutually exclusive with the legacy sandbox fields. */
|
||||
export interface CodexFilesystemPermissions {
|
||||
filesystem: Record<string, CodexFilesystemAccess>;
|
||||
approval: Exclude<CodexSandboxPermissions["approval"], "untrusted">;
|
||||
network_access?: boolean;
|
||||
sandbox?: never;
|
||||
writable_roots?: never;
|
||||
exclude_tmpdir_env_var?: never;
|
||||
exclude_slash_tmp?: never;
|
||||
}
|
||||
|
||||
export type CodexMemberPermissions = CodexSandboxPermissions | CodexFilesystemPermissions;
|
||||
|
||||
const KEYS = ["sandbox", "approval", "network_access", "writable_roots", "exclude_tmpdir_env_var", "exclude_slash_tmp"];
|
||||
const WORKSPACE_ONLY = ["network_access", "writable_roots", "exclude_tmpdir_env_var", "exclude_slash_tmp"];
|
||||
const FILESYSTEM_KEYS = ["filesystem", "approval", "network_access"];
|
||||
const FILESYSTEM_SPECIAL_PATHS = new Set([":root", ":minimal", ":workspace_roots", ":project_roots", ":tmpdir", ":slash_tmp"]);
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
export function validateCodexMemberPermissions(value: unknown, label = "codex_permissions"): CodexMemberPermissions {
|
||||
if (!isRecord(value)) throw new Error(`${label}: expected a mapping with sandbox or filesystem, and approval`);
|
||||
const raw = value;
|
||||
const keys = "filesystem" in raw ? FILESYSTEM_KEYS : KEYS;
|
||||
for (const key of Object.keys(raw)) if (!keys.includes(key)) throw new Error(`${label}.${key}: unsupported key; supported keys are ${keys.join(", ")}`);
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected a mapping with sandbox and approval`);
|
||||
const raw = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(raw)) if (!KEYS.includes(key)) throw new Error(`${label}.${key}: unsupported key; supported keys are ${KEYS.join(", ")}`);
|
||||
const sandbox = raw.sandbox;
|
||||
const approval = raw.approval;
|
||||
if (sandbox === "danger-full-access") throw new Error(`${label}.sandbox: full access is the full_bypass mode, not an envelope; select it with --mode full_bypass`);
|
||||
if (sandbox !== "workspace-write" && sandbox !== "read-only") throw new Error(`${label}.sandbox: expected workspace-write or read-only`);
|
||||
if (approval !== "never" && approval !== "on-request" && approval !== "untrusted") {
|
||||
throw new Error(`${label}.approval: expected never, on-request or untrusted`);
|
||||
}
|
||||
if ("filesystem" in raw) {
|
||||
if (approval === "untrusted") {
|
||||
throw new Error(`${label}.approval: untrusted is not supported with filesystem; expected never or on-request`);
|
||||
}
|
||||
if (!isRecord(raw.filesystem) || Object.keys(raw.filesystem).length === 0) {
|
||||
throw new Error(`${label}.filesystem: expected a nonempty mapping of paths to read, write or deny`);
|
||||
}
|
||||
const filesystem: CodexFilesystemPermissions["filesystem"] = {};
|
||||
for (const [path, access] of Object.entries(raw.filesystem)) {
|
||||
if (/[\0\r\n]/.test(path) || !(isAbsolute(path) || path.startsWith("~/") || FILESYSTEM_SPECIAL_PATHS.has(path))) {
|
||||
throw new Error(`${label}.filesystem: expected an absolute path, ~/path or a supported native special path; got ${JSON.stringify(path)}`);
|
||||
}
|
||||
if (access !== "read" && access !== "write" && access !== "deny") {
|
||||
throw new Error(`${label}.filesystem[${JSON.stringify(path)}]: expected read, write or deny`);
|
||||
}
|
||||
filesystem[path] = access;
|
||||
}
|
||||
if (raw.network_access !== undefined && typeof raw.network_access !== "boolean") throw new Error(`${label}.network_access: expected true or false`);
|
||||
return { approval, filesystem, network_access: raw.network_access === true };
|
||||
}
|
||||
const sandbox = raw.sandbox;
|
||||
if (sandbox === "danger-full-access") throw new Error(`${label}.sandbox: full access is the full_bypass mode, not an envelope; select it with --mode full_bypass`);
|
||||
if (sandbox !== "workspace-write" && sandbox !== "read-only") throw new Error(`${label}.sandbox: expected workspace-write or read-only`);
|
||||
if (sandbox === "read-only") {
|
||||
const extra = WORKSPACE_ONLY.find(key => raw[key] !== undefined);
|
||||
if (extra) throw new Error(`${label}.${extra}: only valid with sandbox workspace-write`);
|
||||
@@ -84,48 +42,26 @@ export function validateCodexMemberPermissions(value: unknown, label = "codex_pe
|
||||
}
|
||||
const roots = raw.writable_roots ?? [];
|
||||
if (!Array.isArray(roots)) throw new Error(`${label}.writable_roots: expected a list of absolute paths`);
|
||||
const writableRoots: string[] = [];
|
||||
roots.forEach((root: unknown, index) => {
|
||||
roots.forEach((root, index) => {
|
||||
if (typeof root !== "string" || !root.trim() || !isAbsolute(root) || /[\0\r\n]/.test(root)) {
|
||||
throw new Error(`${label}.writable_roots[${index}]: expected an absolute path without newlines`);
|
||||
}
|
||||
writableRoots.push(root);
|
||||
});
|
||||
return {
|
||||
sandbox: "workspace-write", approval,
|
||||
network_access: raw.network_access === true, writable_roots: writableRoots,
|
||||
network_access: raw.network_access === true, writable_roots: [...roots] as string[],
|
||||
exclude_tmpdir_env_var: raw.exclude_tmpdir_env_var === true, exclude_slash_tmp: raw.exclude_slash_tmp === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The native policy codexEnvelopeArgs emits for this choice. */
|
||||
export function codexMemberEnvelope(permissions: CodexSandboxPermissions): CodexPermissionEnvelope {
|
||||
export function codexMemberEnvelope(permissions: CodexMemberPermissions): CodexPermissionEnvelope {
|
||||
if (permissions.sandbox === "read-only") return { approvalPolicy: permissions.approval, sandboxPolicy: { type: "read-only" } };
|
||||
return { approvalPolicy: permissions.approval, sandboxPolicy: { type: "workspace-write",
|
||||
writable_roots: permissions.writable_roots ?? [], network_access: permissions.network_access === true,
|
||||
exclude_tmpdir_env_var: permissions.exclude_tmpdir_env_var === true, exclude_slash_tmp: permissions.exclude_slash_tmp === true } };
|
||||
}
|
||||
|
||||
/** Invocation-local profile: never written into the account home or the shared project. */
|
||||
export const CODEX_SEAT_PERMISSION_PROFILE = "openrig-seat";
|
||||
|
||||
export function codexFilesystemOverrides(permissions: CodexFilesystemPermissions): string[] {
|
||||
const entries = Object.entries(permissions.filesystem).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(",");
|
||||
return [`default_permissions=${JSON.stringify(CODEX_SEAT_PERMISSION_PROFILE)}`,
|
||||
`permissions={${JSON.stringify(CODEX_SEAT_PERMISSION_PROFILE)}={filesystem={${entries}},network={enabled=${permissions.network_access === true}}}}`];
|
||||
}
|
||||
|
||||
/** The same authored choice reaches launch, both resumes and the status plan. This reports argv, not enforcement. */
|
||||
export function codexMemberLaunch(permissions: CodexMemberPermissions): { args: string; observation: AppliedLaunchObservation; suppressAutoAddDirs: boolean } {
|
||||
if ("filesystem" in permissions) {
|
||||
return { args: ` -a ${permissions.approval}` + codexFilesystemOverrides(permissions).map(value => ` -c ${shellQuote(value)}`).join(""),
|
||||
observation: { runtime: "codex", axis: "sandbox", state: "observed", value: `permissions:${CODEX_SEAT_PERMISSION_PROFILE}`,
|
||||
reason: "emitted_launch_arguments", ...(permissions.approval === "never" ? { approvalPolicy: "never" } : {}) }, suppressAutoAddDirs: true };
|
||||
}
|
||||
const envelope = codexMemberEnvelope(permissions);
|
||||
return { args: codexEnvelopeArgs(envelope), observation: observeCodexEnvelope(envelope), suppressAutoAddDirs: permissions.sandbox === "read-only" };
|
||||
}
|
||||
|
||||
/** What an emitted envelope's arguments ask for; the native result still needs its own observation. */
|
||||
export function observeCodexEnvelope(envelope: CodexPermissionEnvelope): AppliedLaunchObservation {
|
||||
return { runtime: "codex", axis: "sandbox", state: "observed", value: envelope.sandboxPolicy.type,
|
||||
|
||||
@@ -122,67 +122,57 @@ export const CODEX_NETWORK_READER_DEADLINE_MS = 4_000;
|
||||
|
||||
/** The production reader: asynchronous, so the daemon keeps serving while Codex starts. */
|
||||
export function codexNetworkDefaultReader(options: CodexNetworkReaderOptions = {}): CodexNetworkDefaultReader {
|
||||
const deadlineMs = options.deadlineMs ?? CODEX_NETWORK_READER_DEADLINE_MS;
|
||||
const graceMs = options.graceMs ?? 500;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? 4 * 1024 * 1024;
|
||||
return async (cwd, seatHome) => {
|
||||
const codexHome = seatHome ?? options.codexHome;
|
||||
const started = performance.now();
|
||||
const result = await readCodexConfiguration({ ...options, cwd, codexHome: seatHome ?? options.codexHome,
|
||||
args: ["-c", 'sandbox_mode="workspace-write"', "-c", "features.plugins=false", "-c", "features.apps=false"] });
|
||||
const decision = result.ok ? decideCodexNetworkDefault(result.configRead, result.requirementsRead) : no(result.reason);
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.launchPath ? { PATH: options.launchPath } : {}),
|
||||
...(options.home ? { HOME: options.home } : {}),
|
||||
...(codexHome ? { CODEX_HOME: codexHome } : {}),
|
||||
};
|
||||
// The launch flag `-s workspace-write` is `sandbox_mode` for the server; plugins and apps are
|
||||
// switched off for the reader only. app-server serves stdio by default.
|
||||
const args = ["-c", 'sandbox_mode="workspace-write"', "-c", "features.plugins=false", "-c", "features.apps=false", "app-server"];
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
// Its own process group, so cleanup reaches the reader's descendants and nothing else.
|
||||
child = spawn("codex", args, { cwd, env, stdio: ["pipe", "pipe", "ignore"], detached: true });
|
||||
} catch (error) {
|
||||
return { ...no(`codex app-server could not start: ${firstLine(error)}`), elapsedMs: Math.round(performance.now() - started) };
|
||||
}
|
||||
const decision = await converse(child, cwd, deadlineMs, maxOutputBytes);
|
||||
await stopGroup(child, graceMs);
|
||||
return { ...decision, elapsedMs: Math.round(performance.now() - started) };
|
||||
};
|
||||
}
|
||||
|
||||
export type CodexConfigurationRead = { ok: true; configRead: unknown; requirementsRead: unknown } | { ok: false; reason: string };
|
||||
const unread = (reason: string): CodexConfigurationRead => ({ ok: false, reason });
|
||||
|
||||
/** One native config read with the caller's launch overrides. No thread, turn or persistent server. */
|
||||
export async function readCodexConfiguration(options: CodexNetworkReaderOptions & { cwd: string; args: readonly string[] }): Promise<CodexConfigurationRead> {
|
||||
const deadlineMs = options.deadlineMs ?? CODEX_NETWORK_READER_DEADLINE_MS;
|
||||
const graceMs = options.graceMs ?? 500;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? 4 * 1024 * 1024;
|
||||
const { cwd, codexHome } = options;
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.launchPath ? { PATH: options.launchPath } : {}),
|
||||
...(options.home ? { HOME: options.home } : {}),
|
||||
...(codexHome ? { CODEX_HOME: codexHome } : {}),
|
||||
};
|
||||
// app-server serves stdio by default. The caller states the same permission overrides as its launch.
|
||||
const args = [...options.args, "app-server"];
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
// Its own process group, so cleanup reaches the reader's descendants and nothing else.
|
||||
child = spawn("codex", args, { cwd, env, stdio: ["pipe", "pipe", "ignore"], detached: true });
|
||||
} catch (error) {
|
||||
return unread(`codex app-server could not start: ${firstLine(error)}`);
|
||||
}
|
||||
const decision = await converse(child, cwd, deadlineMs, maxOutputBytes);
|
||||
await stopGroup(child, graceMs);
|
||||
return decision;
|
||||
}
|
||||
|
||||
function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: number, maxOutputBytes: number): Promise<CodexConfigurationRead> {
|
||||
function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: number, maxOutputBytes: number): Promise<CodexNetworkDecision> {
|
||||
return new Promise((resolve) => {
|
||||
const answers = new Map<number, Record<string, unknown>>();
|
||||
let buffered = "";
|
||||
let received = 0;
|
||||
let settled = false;
|
||||
const finish = (decision: CodexConfigurationRead) => {
|
||||
const finish = (decision: CodexNetworkDecision) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(deadline);
|
||||
resolve(decision);
|
||||
};
|
||||
const deadline = setTimeout(() => finish(unread(`codex app-server did not answer within ${deadlineMs} ms`)), deadlineMs);
|
||||
const deadline = setTimeout(() => finish(no(`codex app-server did not answer within ${deadlineMs} ms`)), deadlineMs);
|
||||
const send = (message: Record<string, unknown>) => {
|
||||
if (child.stdin?.writable) child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
child.on("error", (error) => finish(unread(`codex app-server could not start: ${firstLine(error)}`)));
|
||||
child.on("exit", () => finish(unread("codex app-server exited before answering")));
|
||||
child.stdin?.on("error", () => finish(unread("codex app-server closed its input before answering")));
|
||||
child.on("error", (error) => finish(no(`codex app-server could not start: ${firstLine(error)}`)));
|
||||
child.on("exit", () => finish(no("codex app-server exited before answering")));
|
||||
child.stdin?.on("error", () => finish(no("codex app-server closed its input before answering")));
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk: string) => {
|
||||
received += Buffer.byteLength(chunk);
|
||||
if (received > maxOutputBytes) return finish(unread("codex app-server answered with more output than expected"));
|
||||
if (received > maxOutputBytes) return finish(no("codex app-server answered with more output than expected"));
|
||||
buffered += chunk;
|
||||
let newline: number;
|
||||
while ((newline = buffered.indexOf("\n")) >= 0) {
|
||||
@@ -193,7 +183,7 @@ function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: numb
|
||||
try { message = JSON.parse(line); } catch { continue; }
|
||||
// Only responses to our own requests: notifications and server requests carry a method.
|
||||
if (!isRecord(message) || typeof message.id !== "number" || "method" in message) continue;
|
||||
if (message.error !== undefined) return finish(unread(`codex app-server returned an error for request ${message.id}`));
|
||||
if (message.error !== undefined) return finish(no(`codex app-server returned an error for request ${message.id}`));
|
||||
if (message.id === 1) {
|
||||
send({ method: "initialized", params: {} });
|
||||
send({ id: 2, method: "config/read", params: { includeLayers: true, cwd } });
|
||||
@@ -202,7 +192,7 @@ function converse(child: ReturnType<typeof spawn>, cwd: string, deadlineMs: numb
|
||||
}
|
||||
answers.set(message.id, message);
|
||||
if (answers.has(2) && answers.has(3)) {
|
||||
return finish({ ok: true, configRead: answers.get(2)?.result, requirementsRead: answers.get(3)?.result });
|
||||
return finish(decideCodexNetworkDefault(answers.get(2)!.result, answers.get(3)!.result));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,12 +110,9 @@ export function buildCodexResumeDisplay(
|
||||
const profile = input.codexConfigProfile ?? null;
|
||||
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
|
||||
const effectivePosture = input.resolvedPosture ?? "floor";
|
||||
const permissions = input.codexPermissions;
|
||||
const hasFilesystemPolicy = permissions !== undefined && "filesystem" in permissions;
|
||||
const envelope = permissions && !("filesystem" in permissions) ? codexMemberEnvelope(permissions) : undefined;
|
||||
const envelope = input.codexPermissions ? codexMemberEnvelope(input.codexPermissions) : undefined;
|
||||
// Derive displayed flags from the same three inputs adapters/codex-resume.ts uses (member permission envelope,
|
||||
// resolved posture with an empty env so ambient OPENRIG_YOLO never leaks, and operationalLaunchArg).
|
||||
// A filesystem choice currently uses the posture fallback here; label that omission below.
|
||||
const posture = envelope ? profileArg + codexEnvelopeArgs(envelope) : codexPostureArg(profileArg, {}, effectivePosture);
|
||||
const postureArg = posture + operationalLaunchArg(
|
||||
"codex",
|
||||
@@ -166,17 +163,6 @@ export function buildCodexResumeDisplay(
|
||||
&& recorded.state === "observed"
|
||||
&& recorded.value !== null;
|
||||
|
||||
if (hasFilesystemPolicy) {
|
||||
return {
|
||||
command,
|
||||
status: hasObservedRecord ? "recorded_uncertain" : "no_recorded_launch",
|
||||
label: composeCodexResumeLabel(
|
||||
`shown command uses posture fallback ${postureArg.trim()}; does not carry the agent's filesystem policy (managed resume carries it)`,
|
||||
hasLaunchWrapper,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!envelope && input.emptySavedPosture) {
|
||||
const fallbackClause = `saved posture empty, showing fallback ${currentFlags} (managed resume posture unknown)`;
|
||||
if (!hasObservedRecord) {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { NativePermissionStore, type StoredNativePermissionSelection } from "./native-permission-store.js";
|
||||
import { claudePostureFlag, codexPostureArg, planModelAliasNotice, yoloEnabled } from "../adapters/yolo-mode.js";
|
||||
import { codexMemberLaunch } from "./codex-member-permissions.js";
|
||||
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
|
||||
import { codexMemberEnvelope } from "./codex-member-permissions.js";
|
||||
import { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } from "../adapters/kernel-authority.js";
|
||||
import { RigRepository } from "./rig-repository.js";
|
||||
import type { NodeBinding } from "./runtime-adapter.js";
|
||||
@@ -93,7 +94,7 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
|
||||
// A Codex seat's envelope replaces the posture decision with its own flags, as the launch does.
|
||||
const envelope = runtime === "codex" ? planned.codexPermissions : undefined;
|
||||
const nativeArgument = runtime === "claude-code" ? claudePostureFlag(env, posture, mode)
|
||||
: envelope ? `${profile ? ` -p ${profile}` : ""}${codexMemberLaunch(envelope).args}`.trim()
|
||||
: envelope ? `${profile ? ` -p ${profile}` : ""}${codexEnvelopeArgs(codexMemberEnvelope(envelope))}`.trim()
|
||||
: codexPostureArg(profile ? ` -p ${profile}` : "", env, posture).trim();
|
||||
const openRigAdds: PermissionAccount["openRigAdds"] = { claudeAllow: [], claudeAsk: [], notes: [] };
|
||||
const unknown = [NOT_READ, UNVERIFIED,
|
||||
@@ -118,11 +119,7 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
|
||||
controls.push(`rig seat set-permissions ${seat} --rules <file.json> --reason "<why>"`);
|
||||
}
|
||||
if (envelope) {
|
||||
if ("filesystem" in envelope) openRigAdds.notes.push(
|
||||
"Invocation-local filesystem policy; OpenRig adds no writable directories or network grant. No shared policy file is written.",
|
||||
"Each launch reads native configuration and refuses a legacy sandbox or a merged policy mismatch. A named codex_config_profile is unsupported with this choice.",
|
||||
"This status describes the saved plan; native enforcement is not observed here.");
|
||||
else openRigAdds.notes.push(envelope.sandbox === "workspace-write"
|
||||
openRigAdds.notes.push(envelope.sandbox === "workspace-write"
|
||||
? "Extra writable roots for OpenRig's queue state, and git metadata folders on a fresh launch, beside the envelope's own roots."
|
||||
: "No extra writable roots: the envelope is read-only.",
|
||||
`Network access ${envelope.sandbox === "workspace-write" && envelope.network_access ? "on" : "off"}, as the envelope states; OpenRig adds no network setting of its own.`,
|
||||
|
||||
@@ -268,8 +268,7 @@ export function diagnoseRuntimePosture(input: {
|
||||
const launchArguments = applied?.state === "observed" && applied.runtime === input.runtime
|
||||
&& (input.runtime === "claude-code" || input.runtime === "codex");
|
||||
const enforcement = launchArguments
|
||||
? unknownEnforcement(applied.axis, applied.value, null,
|
||||
applied.runtime === "codex" && applied.reason?.startsWith("policy not verified:") ? applied.reason : "native_permission_effect_unverified")
|
||||
? unknownEnforcement(applied.axis, applied.value, null, "native_permission_effect_unverified")
|
||||
: input.runtime === "claude-code"
|
||||
? inspectClaude(input.cwd, input.applied, input.fs)
|
||||
: inspectLaunchBoundRuntime(input.runtime, input.applied);
|
||||
|
||||
@@ -148,9 +148,8 @@ export function assessCurrentStateRehydrateEligibility(
|
||||
typeof session.resume_token === "string" && session.resume_token.length > 0
|
||||
);
|
||||
|
||||
// A resume token is enough for a pod-aware seat too: restore resumes it with an empty plan and names the gap.
|
||||
if (node.podId && !hasStartupContext && !hasResumeToken) {
|
||||
blockers.push(`node ${node.logicalId} is pod-aware but has no persisted startup context or resume token`);
|
||||
if (node.podId && !hasStartupContext) {
|
||||
blockers.push(`node ${node.logicalId} is pod-aware but has no persisted startup context`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -193,16 +193,6 @@ export interface NodeInventoryEntry {
|
||||
tmuxAttachCommand: string | null;
|
||||
latestError: string | null;
|
||||
cwd?: string | null;
|
||||
/** The seat's recorded restore choice and resume token (the inventory carries them). */
|
||||
restorePolicy?: string | null;
|
||||
resumeType?: string | null;
|
||||
resumeToken?: string | null;
|
||||
}
|
||||
|
||||
/** The restore's own resume rule: a seat with a resume token, a resume type and the resume policy resumes. */
|
||||
function seatResumes(node: NodeInventoryEntry): boolean {
|
||||
return !!node.resumeToken && !!node.resumeType && node.resumeType !== "none" && node.runtime !== "terminal"
|
||||
&& (node.restorePolicy ?? "resume_if_possible") === "resume_if_possible";
|
||||
}
|
||||
|
||||
export interface RestoreCheckDeps {
|
||||
@@ -376,7 +366,7 @@ export class RestoreCheckService {
|
||||
// are not skipping (full, --ready/includeReady, or a non-ready seat).
|
||||
const omitReadyDetail = opts.compact && !opts.includeReady && readinessCheck.status === "green";
|
||||
|
||||
const startupContextCheck = this.checkStartupContext(node, rig.name);
|
||||
const startupContextCheck = this.checkStartupContext(node);
|
||||
if ("unknownChecks" in startupContextCheck) {
|
||||
return this.buildUnknown([
|
||||
...checks,
|
||||
@@ -910,7 +900,7 @@ export class RestoreCheckService {
|
||||
};
|
||||
}
|
||||
|
||||
private checkStartupContext(node: NodeInventoryEntry, rigName: string): { check: CheckEntry } | { unknownChecks: CheckEntry[] } {
|
||||
private checkStartupContext(node: NodeInventoryEntry): { check: CheckEntry } | { unknownChecks: CheckEntry[] } {
|
||||
const session = node.canonicalSessionName ?? node.logicalId;
|
||||
const check = `seat.${session}.startup-context`;
|
||||
const runningReady = node.sessionStatus === "running" && node.startupStatus === "ready";
|
||||
@@ -921,7 +911,6 @@ export class RestoreCheckService {
|
||||
check,
|
||||
runningReady,
|
||||
`Startup context cannot be inspected because node id is missing for ${session}`,
|
||||
rigName,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -939,26 +928,9 @@ export class RestoreCheckService {
|
||||
}],
|
||||
};
|
||||
case "missing":
|
||||
// A seat that only ever resumed or was attached has a resume token and no saved startup context. Restore tries its
|
||||
// saved conversation without replaying a saved startup plan, as on any resume (a resume replays no startup context).
|
||||
// A pod-aware seat's restore names the gap; a legacy seat's resume path is unchanged and names none.
|
||||
if (seatResumes(node)) {
|
||||
return {
|
||||
check: {
|
||||
check,
|
||||
status: "yellow",
|
||||
evidence: `${probe.evidence}. Restore tries ${session}'s saved conversation without replaying a saved startup plan${node.podId ? ", and names this gap" : ""}.`,
|
||||
remediation: "No startup-context repair is needed to restore it.",
|
||||
remediationSafe: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: this.buildStartupContextAvailabilityCheck(check, runningReady, probe.evidence, rigName),
|
||||
};
|
||||
case "malformed":
|
||||
return {
|
||||
check: this.buildStartupContextAvailabilityCheck(check, runningReady, probe.evidence, rigName),
|
||||
check: this.buildStartupContextAvailabilityCheck(check, runningReady, probe.evidence),
|
||||
};
|
||||
case "ok":
|
||||
break;
|
||||
@@ -1039,14 +1011,12 @@ export class RestoreCheckService {
|
||||
check: string,
|
||||
runningReady: boolean,
|
||||
evidence: string,
|
||||
rigName: string,
|
||||
): CheckEntry {
|
||||
return {
|
||||
check,
|
||||
status: runningReady ? "yellow" : "red",
|
||||
evidence,
|
||||
// A fresh seat launch reads the saved startup context and refuses without one, so it isn't offered here.
|
||||
remediation: `No command records startup context for this seat yet. To see what restore would do for it, preview with: rig up ${shellQuote(rigName)} --existing --plan (read-only; it records nothing).`,
|
||||
remediation: "Recreate the seat startup context from the rig or agent spec before trusting replay inputs",
|
||||
remediationSafe: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ import type {
|
||||
Session,
|
||||
Checkpoint,
|
||||
RestoreSnapshotSelection,
|
||||
NodeStartupSnapshot,
|
||||
} from "./types.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
@@ -1826,8 +1825,7 @@ export class RestoreOrchestrator {
|
||||
// runtime and write Claude hooks into another agent's working folder
|
||||
// (with a saved context, the compatibility gate already stops that
|
||||
// mismatch), and a pod-aware agent must get no write from this recovery
|
||||
// at all. With no saved context and a resume token, restore tries its resume
|
||||
// on an empty plan (replayCtx below), still without this recovery.
|
||||
// at all — it still ends at the "Harness not started" gate, unchanged.
|
||||
const activityAdapter = opts?.adapters?.["claude-code"];
|
||||
const recoveryRuntime = startupCtx?.runtime
|
||||
?? (!isPodAware && liveRuntime === node.runtime ? node.runtime : null);
|
||||
@@ -2049,20 +2047,6 @@ export class RestoreOrchestrator {
|
||||
const replayContained = resumeRequested && !!resumeToken;
|
||||
const startupRuntime = startupCtx?.runtime ?? node.runtime ?? null;
|
||||
const startupAdapter = seatAdapter(opts?.adapters, startupRuntime, session?.communityHarness);
|
||||
// A seat that only ever resumed or was attached (attach, adopt, reconcile-session, a set resume token) has a resume
|
||||
// token but no saved startup context. A resume replays none anyway (replayContained), so restore tries the resume
|
||||
// with an empty plan and names the gap, rather than stopping on data the resume doesn't use. Guidance, activity hooks
|
||||
// and the guidance record read the saved context only, so no guidance is put back and no startup context is written
|
||||
// for this seat; the adapter's ordinary bootstrap and the seat's existing skill restoration still run, as on any resume.
|
||||
const resumeWithoutContext = isPodAware && !startupCtx && replayContained && startupRuntime !== null && startupRuntime !== "terminal";
|
||||
// A fresh seat launch reads the saved startup context and refuses without one, so a rollback names none for this seat.
|
||||
const noContextFresh = "This seat has no saved startup context, and no command records one for it yet, so no fresh start is offered here.";
|
||||
const replayCtx: NodeStartupSnapshot | null = startupCtx
|
||||
?? (resumeWithoutContext ? { runtime: startupRuntime!, projectionEntries: [], resolvedStartupFiles: [], startupActions: [] } : null);
|
||||
if (resumeWithoutContext && startupAdapter && launchResult) {
|
||||
warnings?.push(`Restore: ${node.logicalId}: no saved startup context, so restore tries its saved conversation without replaying a saved startup plan.`);
|
||||
activityHookGaps?.push("startup_context: no saved startup context; restore tried the saved conversation without replaying a saved startup plan.");
|
||||
}
|
||||
// New compute has no retained context filesystem. Restore the saved file selection, never conversation actions.
|
||||
const restoreEnvironmentContext = replayContained && !!startupAdapter?.environment && !!data.environmentBindingsByNode?.[node.id];
|
||||
|
||||
@@ -2075,14 +2059,13 @@ export class RestoreOrchestrator {
|
||||
&& baseStatus !== "resumed"
|
||||
&& startupRuntime !== null
|
||||
&& startupRuntime !== "terminal"
|
||||
&& (!replayCtx || !startupAdapter)
|
||||
&& (!startupCtx || !startupAdapter)
|
||||
) {
|
||||
// A deliberate fresh launch with no captured startup context is the
|
||||
// never-occupied-seat case from #49. The restore launcher has already
|
||||
// created a shell, so remove that provisional session before handing the
|
||||
// seat to the normal recipe-backed launch path. A resume with a token
|
||||
// goes on with an empty plan (replayCtx above); a resume without one stays
|
||||
// on the fail-closed path below: no durable context means no safe resume.
|
||||
// seat to the normal recipe-backed launch path. Resume requests stay on
|
||||
// the fail-closed path below: no durable context means no safe resume.
|
||||
if (!startupCtx && !resumeRequested && this.freshLaunch) {
|
||||
// Keep the usable provisional shell when the callback exists but its
|
||||
// recipe/member inputs are unavailable. A callback being present alone
|
||||
@@ -2119,23 +2102,23 @@ export class RestoreOrchestrator {
|
||||
};
|
||||
}
|
||||
}
|
||||
const cause = replayCtx ? `no ${startupRuntime} runtime adapter` : "no startup context";
|
||||
const cause = startupCtx ? `no ${startupRuntime} runtime adapter` : "no startup context";
|
||||
const error = `Harness not started: ${cause} for ${node.logicalId}.`;
|
||||
warnings?.push(`Restore: ${error}`);
|
||||
return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error };
|
||||
}
|
||||
|
||||
// Attempt restore-safe startup replay if context available
|
||||
if ((data.nodeStartupContext || replayCtx) && opts?.adapters && launchResult) {
|
||||
if (replayCtx) {
|
||||
const adapter = seatAdapter(opts.adapters, replayCtx.runtime, session?.communityHarness);
|
||||
if (data.nodeStartupContext && opts?.adapters && launchResult) {
|
||||
if (startupCtx) {
|
||||
const adapter = seatAdapter(opts.adapters, startupCtx.runtime, session?.communityHarness);
|
||||
if (adapter) {
|
||||
// Prefilter: check which files/entries still exist
|
||||
const existsFn = opts.fsOps?.exists ?? (() => true);
|
||||
const sourceEntries = replayContained && !restoreEnvironmentContext ? [] : replayCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
|
||||
const sourceEntries = replayContained && !restoreEnvironmentContext ? [] : startupCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
|
||||
// #261: recognized built-in startup files follow the running install.
|
||||
const sourceFiles = replayContained && !restoreEnvironmentContext ? launchGuidance ?? [] : withContextEntryFloor(replayCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn)));
|
||||
const sourceActions = replayContained ? [] : replayCtx.startupActions;
|
||||
const sourceFiles = replayContained && !restoreEnvironmentContext ? launchGuidance ?? [] : withContextEntryFloor(startupCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn)));
|
||||
const sourceActions = replayContained ? [] : startupCtx.startupActions;
|
||||
const filteredEntries = sourceEntries.filter((e) => {
|
||||
if (!existsFn(e.absolutePath)) {
|
||||
warnings?.push(`Restore: missing projection entry ${e.absolutePath} (skipped)`);
|
||||
@@ -2173,7 +2156,7 @@ export class RestoreOrchestrator {
|
||||
// it is, an edited one is named, and a missing one whose recorded source is gone or has changed is not put back
|
||||
// with today's bytes. Only the rest is projected or installed. Nothing is re-resolved from today's spec. Native
|
||||
// resume replays nothing (sourceEntries is empty). The whole selection stays in the saved startup context.
|
||||
const seatSkills = (replayCtx.runtime === "claude-code" || replayCtx.runtime === "codex") && !replayContained ? seatSkillPathsForNode(this.db, node.id) : undefined;
|
||||
const seatSkills = (startupCtx.runtime === "claude-code" || startupCtx.runtime === "codex") && !replayContained ? seatSkillPathsForNode(this.db, node.id) : undefined;
|
||||
let replaySkip: { entryIds: Set<string>; files: Set<string> } | undefined;
|
||||
if (seatSkills) {
|
||||
const replay = seatSkillReplay({ entries: filteredEntries, files: filteredFiles, paths: seatSkills,
|
||||
@@ -2184,7 +2167,7 @@ export class RestoreOrchestrator {
|
||||
|
||||
// Build fresh projection plan (all safe_projection; replaySkip says which seat skills are left alone)
|
||||
const plan: import("./projection-planner.js").ProjectionPlan = {
|
||||
runtime: replayCtx.runtime,
|
||||
runtime: startupCtx.runtime,
|
||||
cwd: launchCwd,
|
||||
entries: filteredEntries.map((e) => ({
|
||||
...e,
|
||||
@@ -2201,7 +2184,7 @@ export class RestoreOrchestrator {
|
||||
const binding = {
|
||||
...launchResult.binding,
|
||||
// #875: the guidance file this seat's launch recorded; an older snapshot recorded none, so its file stays the rule's.
|
||||
guidanceFile: replayCtx.guidanceFile ?? null,
|
||||
guidanceFile: startupCtx.guidanceFile ?? null,
|
||||
// #3039: the resumed launch carries the guidance chosen above, so startup doesn't ask again and the adapter queues it.
|
||||
...(launchGuidance ? { guidanceLaunchInstructions: true as const } : {}),
|
||||
...(data.environmentBindingsByNode?.[node.id] ? { environmentRestore: data.environmentBindingsByNode[node.id] } : {}),
|
||||
@@ -2271,9 +2254,7 @@ export class RestoreOrchestrator {
|
||||
nodeId: node.id,
|
||||
logicalId: node.logicalId,
|
||||
status: "awaiting-decision",
|
||||
error: resumeWithoutContext
|
||||
? `Original session unresumable: resume attempted but the runtime reported fresh continuity. The blank session was rolled back; no session is running. ${noContextFresh}`
|
||||
: `Original session unresumable: resume attempted but the runtime reported fresh continuity. The blank session was rolled back; no session is running. If that degradation is acceptable, start only this seat fresh: ${this.freshSeatCommand(rigId, data, node.logicalId)}.`,
|
||||
error: `Original session unresumable: resume attempted but the runtime reported fresh continuity. The blank session was rolled back; no session is running. If that degradation is acceptable, start only this seat fresh: ${this.freshSeatCommand(rigId, data, node.logicalId)}.`,
|
||||
};
|
||||
}
|
||||
const finalStatus = (isPodAware && resumeRequested)
|
||||
@@ -2319,9 +2300,7 @@ export class RestoreOrchestrator {
|
||||
nodeId: node.id,
|
||||
logicalId: node.logicalId,
|
||||
status: "awaiting-decision",
|
||||
error: resumeWithoutContext
|
||||
? `Original session unresumable: ${startupResult.errors.join("; ")}. The blank session was rolled back; no session is running. ${noContextFresh} Restore the original session manually.`
|
||||
: `Original session unresumable: ${startupResult.errors.join("; ")}. The blank session was rolled back; no session is running. OpenRig's records can't tell whether this conversation was ever used. If you know this seat was never used, nothing is lost by starting only this seat fresh, ${steps}. Otherwise restore the original session manually.`,
|
||||
error: `Original session unresumable: ${startupResult.errors.join("; ")}. The blank session was rolled back; no session is running. OpenRig's records can't tell whether this conversation was ever used. If you know this seat was never used, nothing is lost by starting only this seat fresh, ${steps}. Otherwise restore the original session manually.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -131,7 +131,7 @@ export function addRigEdge(
|
||||
if (LAUNCH_DEPENDENCY_KINDS.has(input.kind) && createsLaunchCycle(rig.edges, candidate)) {
|
||||
return {
|
||||
ok: false, code: "edge_cycle",
|
||||
message: `a ${input.kind} edge from ${input.from} to ${input.to} would make launch order cycle; launch and restore require an acyclic order`,
|
||||
message: `a ${input.kind} edge from ${input.from} to ${input.to} would make launch order cycle, and launch and restore refuse a cycle`,
|
||||
};
|
||||
}
|
||||
if (input.plan) return { ok: true, rigId: input.rigId, outcome: "would_add", edge };
|
||||
|
||||
@@ -253,9 +253,6 @@ export interface RuntimeAdapter {
|
||||
/** Claude's managed capability/launch seam, shared with seat selection. */
|
||||
readonly claudeManagedLaunch?: import("./claude-managed-launch.js").ClaudeManagedLaunch;
|
||||
readonly runtime: string;
|
||||
/** Check launch preconditions before an in-place handover ends the predecessor.
|
||||
* A concrete conflict refuses; an inconclusive check may allow launch with a warning. */
|
||||
preflightLaunch?(binding: NodeBinding): Promise<{ ok: true; warnings?: string[] } | { ok: false; error: string }>;
|
||||
/** The file an adapter's project() writes for a skill when it lives outside
|
||||
* the Claude project tree, for "already in place" detection. Absent = the
|
||||
* instantiator's default target. */
|
||||
|
||||
@@ -518,9 +518,7 @@ export class SeatHandoverService {
|
||||
if (permissionOverride.codexPermissions) {
|
||||
// Saved envelopes retain ordinary launch additions; only an observed predecessor
|
||||
// policy uses the exact carry path. Describe policy, not an observed native delta.
|
||||
const directoryPolicy = "filesystem" in permissionOverride.codexPermissions
|
||||
? "with its explicit filesystem policy and no additional writable directory grants"
|
||||
: permissionOverride.codexPermissions.sandbox === "workspace-write"
|
||||
const directoryPolicy = permissionOverride.codexPermissions.sandbox === "workspace-write"
|
||||
? "with OpenRig's usual Git metadata, seat-folder and pod-state writable roots as on a fresh launch"
|
||||
: "without additional writable directory grants, as on a read-only fresh launch";
|
||||
permissionContinuity = { state: "selection_used", detail: `The seat's saved permission envelope was used ${directoryPolicy}. This describes launch policy, not an observed difference. The predecessor policy was not carried; native successor equivalence is unverified.`
|
||||
@@ -619,9 +617,7 @@ export class SeatHandoverService {
|
||||
// The registry binding is unchanged (commit never ran). A resolve_pane failure leaves the live
|
||||
// retiree wholly untouched; a failure after the in-place respawn leaves the seat re-wakeable from
|
||||
// its provider session file (never destroyed). Inspect tmux/daemon logs and retry.
|
||||
guidance: launch.code === "successor_preflight_failed"
|
||||
? `The predecessor is still running and its binding is unchanged. ${launch.message}`
|
||||
: "The seat's registry binding is unchanged. If the failure was after the in-place respawn, the seat is re-wakeable from its provider session file. Inspect tmux/daemon logs and retry.",
|
||||
guidance: "The seat's registry binding is unchanged. If the failure was after the in-place respawn, the seat is re-wakeable from its provider session file. Inspect tmux/daemon logs and retry.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -783,7 +779,7 @@ export class SeatHandoverService {
|
||||
sourceOutcome,
|
||||
launchAdditionNotes,
|
||||
startedWithoutRequired,
|
||||
launchWarnings: [...coreCopyNotes, ...(launch.warnings ?? [])],
|
||||
launchWarnings: coreCopyNotes,
|
||||
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -951,7 +951,7 @@ export class SeatLifecycleService {
|
||||
let persisted: PersistedEvent | null = null;
|
||||
const result = this.db.transaction(() => {
|
||||
// Read the current record for the audit; malformed old bytes stay visible as such and are replaced.
|
||||
const row = this.db.prepare("SELECT codex_permissions_json, codex_config_profile FROM nodes WHERE id = ? AND runtime = 'codex'").get(seat.nodeId) as { codex_permissions_json: string | null; codex_config_profile: string | null } | undefined;
|
||||
const row = this.db.prepare("SELECT codex_permissions_json FROM nodes WHERE id = ? AND runtime = 'codex'").get(seat.nodeId) as { codex_permissions_json: string | null } | undefined;
|
||||
if (!row) throw new Error("Seat runtime changed; the envelope was not changed.");
|
||||
let from: unknown = null;
|
||||
try { from = readCodexMemberPermissions(this.db, seat.nodeId) ?? null; }
|
||||
@@ -968,7 +968,7 @@ export class SeatLifecycleService {
|
||||
nodeId: seat.nodeId, from, to, ...(replaced ? { replaced } : {}), actor, reason, effect: "future_launches_only" });
|
||||
}
|
||||
return { ok: true as const, seat, from, to, changed, ...(replaced ? { replaced } : {}),
|
||||
effect: `The envelope applies at the next separately authorized managed launch or resume, ${"filesystem" in to ? "as explicit permission and approval flags, with native configuration conflicts refused and inconclusive checks reported as policy not verified." : "as explicit sandbox and approval flags that a profile or policy posture doesn't override."}${replaced ? " It replaces the seat's recorded permission mode." : ""} The current process and history are unchanged. Older readers may ignore it; check rollback compatibility and preserve restrictions before downgrading.` };
|
||||
effect: `The envelope applies at the next separately authorized managed launch or resume, as explicit sandbox and approval flags that a profile or policy posture doesn't override.${replaced ? " It replaces the seat's recorded permission mode." : ""} The current process and history are unchanged. Older readers may ignore it; check rollback compatibility and preserve restrictions before downgrading.` };
|
||||
})();
|
||||
if (persisted) this.eventBus.notifySubscribers(persisted);
|
||||
return result;
|
||||
|
||||
@@ -3930,13 +3930,12 @@ export class SessionTransport {
|
||||
|
||||
/** Wait on the existing classifier without occupying the input/lifecycle lease. With `singleObservation`, one
|
||||
* observation within `timeoutMs` and no wait after it: a busy reading returns at once. */
|
||||
async waitUntilIdle(sessionName: string, timeoutMs: number, signal?: AbortSignal, opts?: { singleObservation?: boolean; allowDraft?: true; onObservation?: (activity: AgentActivity) => void }) {
|
||||
async waitUntilIdle(sessionName: string, timeoutMs: number, signal?: AbortSignal, opts?: { singleObservation?: boolean; allowDraft?: true }) {
|
||||
const meta = this.getSessionMeta(sessionName);
|
||||
return this.waitForIdle({ sessionName, runtime: meta.runtime, attachmentType: meta.attachmentType, timeoutMs, signal,
|
||||
binding: { sessionName, nodeId: meta.nodeId, occupant: meta.occupant, pane: meta.pane },
|
||||
...(opts?.allowDraft ? { allowDraft: true } : {}),
|
||||
...(opts?.singleObservation ? { singleObservation: true } : {}),
|
||||
...(opts?.onObservation ? { onObservation: opts.onObservation } : {}) });
|
||||
...(opts?.singleObservation ? { singleObservation: true } : {}) });
|
||||
}
|
||||
|
||||
private async waitForIdle(input: {
|
||||
@@ -3955,8 +3954,6 @@ export class SessionTransport {
|
||||
allowUnknown?: boolean;
|
||||
/** One observation only: a reading that isn't idle ends the wait at once. */
|
||||
singleObservation?: boolean;
|
||||
/** Called with each observation that completes in time, so a caller can report the seat's state while it waits. */
|
||||
onObservation?: (activity: AgentActivity) => void;
|
||||
// Optional display value for timeout messages. The deadline always comes
|
||||
// from timeoutMs; when set, expiry reports this value instead, so a
|
||||
// re-wait running on a leftover budget still names the configured wait.
|
||||
@@ -4027,7 +4024,6 @@ export class SessionTransport {
|
||||
return expire();
|
||||
}
|
||||
last = activity;
|
||||
input.onObservation?.(activity);
|
||||
const waitedMs = input.timeoutMs - (deadline - Date.now());
|
||||
|
||||
if (activity.state === "idle" || (input.allowUnknown && activity.state === "unknown") || (input.allowDraft && this.deliversOverDraft(activity))) {
|
||||
|
||||
@@ -250,21 +250,6 @@ export class SuccessorSessionLauncher {
|
||||
};
|
||||
}
|
||||
|
||||
const binding = successorBinding(input.node, departingSession, pane.id, cwd, input.occupantGeneration);
|
||||
const adapter = input.node.runtime ? this.runtimeAdapters[input.node.runtime] : undefined;
|
||||
let preflightWarnings: string[] = [];
|
||||
if (adapter?.preflightLaunch) {
|
||||
try {
|
||||
const checked = await adapter.preflightLaunch(binding);
|
||||
if (!checked.ok) return { ok: false, code: "successor_preflight_failed", step: "create_successor",
|
||||
message: checked.error, replacementStarted: false };
|
||||
preflightWarnings = checked.warnings ?? [];
|
||||
} catch (error) {
|
||||
return { ok: false, code: "successor_preflight_failed", step: "create_successor",
|
||||
message: `Successor launch preflight threw: ${error instanceof Error ? error.message : String(error)}`, replacementStarted: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Terminate the retiree IN PLACE, then respawn the now-dead pane WITHOUT -k so native scrollback
|
||||
// survives (respawn-pane -k CLEARS the pane history — verified on tmux 3.6a; that would defeat the
|
||||
// money-proof). The swap: set remain-on-exit so the pane survives the exit → graceful SIGTERM
|
||||
@@ -319,7 +304,7 @@ export class SuccessorSessionLauncher {
|
||||
// not repointed. When the harness launch was already sent and only readiness failed, the pane is NOT left as
|
||||
// a bare shell: the successor may still come up there, unadopted (#2985). The failure says so, so the caller
|
||||
// can tell the operator that a retry would end it.
|
||||
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration, undefined, binding);
|
||||
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration);
|
||||
if (!started.ok) {
|
||||
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true,
|
||||
tmuxPane: pane.id, ...(started.launched ? { successorLaunched: true } : {}) };
|
||||
@@ -335,7 +320,6 @@ export class SuccessorSessionLauncher {
|
||||
cwd: input.node.cwd ?? undefined,
|
||||
});
|
||||
|
||||
const warnings = [...preflightWarnings, ...(respawned.warnings ?? [])];
|
||||
return {
|
||||
ok: true,
|
||||
discoveredId: discovered.id,
|
||||
@@ -346,7 +330,7 @@ export class SuccessorSessionLauncher {
|
||||
appliedLaunch: started.appliedLaunch,
|
||||
...(started.activityGrantId ? { activityGrantId: started.activityGrantId } : {}),
|
||||
retireeExit: terminated.path,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(respawned.warnings ? { warnings: respawned.warnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -413,7 +397,6 @@ export class SuccessorSessionLauncher {
|
||||
forkSource?: ForkSource,
|
||||
launchGeneration?: string | null,
|
||||
staged?: StagedLaunch,
|
||||
preparedBinding?: NodeBinding,
|
||||
): Promise<{ ok: true; activityGrantId?: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
|
||||
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
|
||||
if (!adapter) {
|
||||
@@ -431,7 +414,39 @@ export class SuccessorSessionLauncher {
|
||||
// edge of the same seat, so its launch must read the seat's spec, else the
|
||||
// running topology silently drifts from the founder-designed one at every
|
||||
// handover. (config profile is still a tracked follow-on.)
|
||||
const binding = preparedBinding ?? successorBinding(node, tmuxSession, tmuxPane, cwd, launchGeneration, staged?.binding);
|
||||
const binding: NodeBinding = {
|
||||
id: "",
|
||||
nodeId: node.id,
|
||||
attachmentType: "tmux",
|
||||
tmuxSession,
|
||||
tmuxWindow: null,
|
||||
tmuxPane,
|
||||
cmuxWorkspace: null,
|
||||
cmuxSurface: null,
|
||||
updatedAt: "",
|
||||
cwd: cwd ?? "",
|
||||
launchGeneration: launchGeneration ?? undefined,
|
||||
nonInterruptive: node.nonInterruptive,
|
||||
kernelAuthority: node.kernelAuthority,
|
||||
teamPermissionDefault: node.teamPermissionDefault,
|
||||
claudePermissions: node.claudePermissions,
|
||||
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp : undefined,
|
||||
codexPermissions: node.codexPermissions,
|
||||
// Seam B: continuity — the successor launches at the departing seat's posture.
|
||||
...(node.launchPosture ? { launchPosture: node.launchPosture } : {}),
|
||||
...(node.permissionMode ? { permissionMode: node.permissionMode } : {}),
|
||||
// 0.5.2-07: the successor reads the seat's SPEC-pinned model (adapter emits -m/--model).
|
||||
model: node.model ?? undefined,
|
||||
// #75: the successor reads the seat's resolved effort (saved choice, else authored).
|
||||
effort: node.effort ?? undefined,
|
||||
advisor: node.advisor ?? undefined,
|
||||
// 0.5.2-07 A4-profile: the successor reads the seat's SPEC-pinned codex config profile (adapter emits -p).
|
||||
codexConfigProfile: node.codexConfigProfile ?? undefined,
|
||||
codexRebuildPermissions: node.codexRebuildPermissions,
|
||||
...(node.launchAdditions ? { launchAdditions: node.launchAdditions } : {}),
|
||||
...(node.launchWrapper?.length ? { launchWrapper: node.launchWrapper } : {}),
|
||||
...(staged?.binding ?? {}),
|
||||
};
|
||||
|
||||
// Slice 16: a staged runtime-switch successor gets its target materials before its harness starts.
|
||||
let notes: string[] | undefined;
|
||||
@@ -563,43 +578,6 @@ export class SuccessorSessionLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
function successorBinding(node: SuccessorNode, tmuxSession: string, tmuxPane: string, cwd: string | undefined,
|
||||
launchGeneration?: string | null, bindingOverrides?: StagedLaunch["binding"]): NodeBinding {
|
||||
return {
|
||||
id: "",
|
||||
nodeId: node.id,
|
||||
attachmentType: "tmux",
|
||||
tmuxSession,
|
||||
tmuxWindow: null,
|
||||
tmuxPane,
|
||||
cmuxWorkspace: null,
|
||||
cmuxSurface: null,
|
||||
updatedAt: "",
|
||||
cwd: cwd ?? "",
|
||||
launchGeneration: launchGeneration ?? undefined,
|
||||
nonInterruptive: node.nonInterruptive,
|
||||
kernelAuthority: node.kernelAuthority,
|
||||
teamPermissionDefault: node.teamPermissionDefault,
|
||||
claudePermissions: node.claudePermissions,
|
||||
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp : undefined,
|
||||
codexPermissions: node.codexPermissions,
|
||||
// Seam B: continuity — the successor launches at the departing seat's posture.
|
||||
...(node.launchPosture ? { launchPosture: node.launchPosture } : {}),
|
||||
...(node.permissionMode ? { permissionMode: node.permissionMode } : {}),
|
||||
// 0.5.2-07: the successor reads the seat's SPEC-pinned model (adapter emits -m/--model).
|
||||
model: node.model ?? undefined,
|
||||
// #75: the successor reads the seat's resolved effort (saved choice, else authored).
|
||||
effort: node.effort ?? undefined,
|
||||
advisor: node.advisor ?? undefined,
|
||||
// 0.5.2-07 A4-profile: the successor reads the seat's SPEC-pinned codex config profile (adapter emits -p).
|
||||
codexConfigProfile: node.codexConfigProfile ?? undefined,
|
||||
codexRebuildPermissions: node.codexRebuildPermissions,
|
||||
...(node.launchAdditions ? { launchAdditions: node.launchAdditions } : {}),
|
||||
...(node.launchWrapper?.length ? { launchWrapper: node.launchWrapper } : {}),
|
||||
...(bindingOverrides ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function compactEnv(input: Record<string, string | undefined>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
|
||||
@@ -353,28 +353,6 @@ export class WorkflowInstanceStore {
|
||||
return rows.map(rowToInstance);
|
||||
}
|
||||
|
||||
/** Apply selectors before the optional bound and before expensive inspection. */
|
||||
listFiltered(filters: { status?: WorkflowInstanceStatus; project?: string; mission?: string; slice?: string; rig?: string; name?: string; tags?: string[]; limit?: number }): WorkflowInstance[] {
|
||||
const clauses: string[] = [];
|
||||
const args: Array<string | number> = [];
|
||||
if (filters.status) { clauses.push("status = ?"); args.push(filters.status); }
|
||||
if (filters.name) { clauses.push("workflow_name = ?"); args.push(filters.name); }
|
||||
if (filters.rig) { clauses.push(this.hasBoundRigColumn ? "bound_rig = ?" : "0"); if (this.hasBoundRigColumn) args.push(filters.rig); }
|
||||
for (const field of ["project", "mission", "slice"] as const) {
|
||||
if (!filters[field]) continue;
|
||||
clauses.push(this.hasScopeColumn ? `json_extract(scope_json, '$.${field}') = ?` : "0");
|
||||
if (this.hasScopeColumn) args.push(filters[field]!);
|
||||
}
|
||||
for (const tag of filters.tags ?? []) {
|
||||
clauses.push(this.hasScopeColumn ? "EXISTS (SELECT 1 FROM json_each(workflow_instances.scope_json, '$.tags') WHERE value = ?)" : "0");
|
||||
if (this.hasScopeColumn) args.push(tag);
|
||||
}
|
||||
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
||||
const limit = filters.limit === undefined ? "" : " LIMIT ?";
|
||||
if (filters.limit !== undefined) args.push(filters.limit);
|
||||
return (this.db.prepare(`SELECT * FROM workflow_instances${where} ORDER BY created_at ASC${limit}`).all(...args) as InstanceRow[]).map(rowToInstance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frontier + status atomically. Caller is responsible for
|
||||
* wrapping in a transaction when this needs to compose with other
|
||||
|
||||
@@ -1845,9 +1845,8 @@ export class WorkflowRuntime {
|
||||
/** List instances (optionally filtered) with the deadline verdict attached. */
|
||||
listInstancesWithDeadline(
|
||||
status?: "active" | "waiting" | "completed" | "failed" | "aborted",
|
||||
filters?: Omit<Parameters<WorkflowInstanceStore["listFiltered"]>[0], "status">,
|
||||
): WorkflowInstanceWithInspection[] {
|
||||
const rows = filters ? this.instanceStore.listFiltered({ ...filters, status }) : status ? this.instanceStore.listByStatus(status) : this.instanceStore.listAll();
|
||||
const rows = status ? this.instanceStore.listByStatus(status) : this.instanceStore.listAll();
|
||||
return rows.map((row) => {
|
||||
const inspected = this.inspect(row.instanceId);
|
||||
return {
|
||||
|
||||
@@ -63,8 +63,8 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
}
|
||||
|
||||
// Read the KNOWN context-usage projection BEFORE triggering. When usage is
|
||||
// absent / stale / unknown we pass null (never invent a value); an explicit
|
||||
// manual request doesn't depend on the threshold, so it proceeds without it.
|
||||
// absent / stale / unknown we pass null (never invent a value); the enforcer
|
||||
// returns an honest `no_usage_data` reason for a Claude seat.
|
||||
const usage = usageStore.getForNode(row.node_id, sessionName);
|
||||
const outcome = await enforcer.triggerManualCompact(
|
||||
{
|
||||
@@ -170,8 +170,7 @@ function manualOutcomeMessage(sessionName: string, outcome: Extract<ManualCompac
|
||||
const prep = outcome.preparation;
|
||||
if (!prep || prep.delivery === "not_sent") return manualReasonMessage(sessionName, outcome.reason);
|
||||
const effect = prep.delivery === "delivered" ? "Preparation was sent" : "Preparation may have reached the seat (delivery is unconfirmed)";
|
||||
const detail = (outcome.reason === "preparation_incomplete" || outcome.reason === "map_complete_seat_never_idle"
|
||||
|| outcome.reason === "preparation_needs_input")
|
||||
const detail = (outcome.reason === "preparation_incomplete" || outcome.reason === "map_complete_seat_never_idle")
|
||||
? manualReasonMessage(sessionName, outcome.reason)
|
||||
: outcome.restoreOwed
|
||||
? `/compact was not confirmed (${outcome.reason}) and is never sent again. Its restore steps follow in case it ran; the restore request tells the seat to say so and skip if it didn't compact.`
|
||||
@@ -203,8 +202,6 @@ function manualReasonMessage(sessionName: string, reason: string): string {
|
||||
return `The occupant generation could not be resolved for '${sessionName}'; no preparation or compact was sent. For the confirmed live seat, run rig reconcile-session ${shellQuote(sessionName)} --no-launch, then explicitly retry rig compact ${shellQuote(sessionName)}. Reconciliation does not restart this attempt.`;
|
||||
case "map_complete_seat_never_idle":
|
||||
return `Compaction for '${sessionName}': map complete, seat never idle within the bounded wait; managed compaction is disarmed. Inspect rig compact ${shellQuote(sessionName)} --state before an explicit retry.`;
|
||||
case "preparation_needs_input":
|
||||
return `Compaction for '${sessionName}' waited for the seat to answer a prompt (a permission request in its preparation turn, say) and it didn't become idle within the bounded wait; managed compaction is disarmed. Answer the prompt in the seat, then retry explicitly: rig compact ${shellQuote(sessionName)}. Inspect rig compact ${shellQuote(sessionName)} --state first.`;
|
||||
case "preparation_incomplete":
|
||||
return `The restore map was not completed in time for '${sessionName}'; managed compaction is disarmed. Inspect rig compact ${sessionName} --state, retry explicitly, or use --skip-map once.`;
|
||||
case "pane_unknown_protected":
|
||||
|
||||
@@ -321,20 +321,10 @@ export function workflowRoutes(): Hono {
|
||||
// runtime.continue()'s enriched instance.
|
||||
app.get("/list", (c) => {
|
||||
const status = c.req.query("status");
|
||||
const parsedStatus = status === "active" || status === "waiting" || status === "completed" || status === "failed" || status === "aborted" ? status : undefined;
|
||||
const rawLimit = c.req.query("limit");
|
||||
if (rawLimit !== undefined && (!/^[1-9]\d*$/.test(rawLimit) || Number(rawLimit) > 10000)) return c.json({ error: "limit_invalid", message: "limit must be an integer from 1 to 10000" }, 400);
|
||||
const fields = ["project", "mission", "slice", "rig", "name"] as const;
|
||||
const filters: { project?: string; mission?: string; slice?: string; rig?: string; name?: string; tags?: string[]; limit?: number } = {};
|
||||
for (const field of fields) {
|
||||
const value = c.req.query(field);
|
||||
if (value !== undefined) { if (!value.trim()) return c.json({ error: "filter_invalid", message: `${field} must not be empty` }, 400); filters[field] = value; }
|
||||
if (status === "active" || status === "waiting" || status === "completed" || status === "failed" || status === "aborted") {
|
||||
return c.json(getRuntime(c).listInstancesWithDeadline(status));
|
||||
}
|
||||
const tags = c.req.queries("tag");
|
||||
if (tags?.some((tag) => !tag.trim())) return c.json({ error: "filter_invalid", message: "tag must not be empty" }, 400);
|
||||
if (tags?.length) filters.tags = tags;
|
||||
if (rawLimit !== undefined) filters.limit = Number(rawLimit);
|
||||
return c.json(getRuntime(c).listInstancesWithDeadline(parsedStatus, Object.keys(filters).length ? filters : undefined));
|
||||
return c.json(getRuntime(c).listInstancesWithDeadline());
|
||||
});
|
||||
|
||||
// Lists every cached workflow_spec with an `isBuiltIn` flag computed
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { diagnoseRuntimePosture, observeClaudePermission, renderPermissionDriftSummary } from "../src/domain/permission-drift.js";
|
||||
import { observeClaudePermission } from "../src/domain/permission-drift.js";
|
||||
|
||||
describe("AppliedLaunchObservationStore", () => {
|
||||
let db: DatabaseType;
|
||||
@@ -47,23 +47,6 @@ describe("AppliedLaunchObservationStore", () => {
|
||||
expect(db.prepare("SELECT COUNT(*) AS n FROM applied_launch_observations").get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it("preserves an inconclusive Codex policy reason through storage and rendered status", () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'codex' WHERE id = 'node-1'").run();
|
||||
registry.registerSession("node-1", "dev-impl@r1");
|
||||
const generation = registry.currentOccupantTenure("node-1")!.generationUuid;
|
||||
const reason = "policy not verified: native read timed out";
|
||||
expect(store.recordGeneration(generation, { runtime: "codex", axis: "sandbox", state: "observed",
|
||||
value: "permissions:openrig-seat", reason })).toBe(true);
|
||||
const applied = store.readCurrent("node-1");
|
||||
expect(applied?.reason).toBe(reason);
|
||||
const diagnostic = diagnoseRuntimePosture({ runtime: "codex", cwd: "/work", applied,
|
||||
fs: { readFile: () => "", cwdReadable: () => true, commandAvailable: () => true, claudePermissionModes: () => null } });
|
||||
expect(diagnostic.enforcement).toMatchObject({ state: "unknown", reason });
|
||||
expect(renderPermissionDriftSummary(diagnostic)).toContain(reason);
|
||||
registry.mintOccupantTenure("node-1", "handover");
|
||||
expect(store.readCurrent("node-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("never rebinds a delayed launch write onto a successor generation", () => {
|
||||
registry.registerSession("node-1", "dev-impl@r1");
|
||||
const launchedGeneration = registry.currentOccupantTenure("node-1")!.generationUuid;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// usage data short-circuit, send-failure no-dedup-update semantics.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { ClaudeCompactionEnforcer as ActualEnforcer, MANUAL_SEAT_READ_INTERVAL_MS, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { ClaudeCompactionEnforcer as ActualEnforcer, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS, type SessionTransport } from "../src/domain/session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
@@ -1199,7 +1199,7 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("preparing");
|
||||
|
||||
// DEGRADED duplicate: usedPercentage null (bad-sidecar projection). The guard
|
||||
// PRECEDES every manual refusal, so it must NOT record
|
||||
// now PRECEDES the no_usage_data recordManualFailure path, so it must NOT record
|
||||
// skipped-or-failed / erase the preparing marker (the bug rev1-r2 caught).
|
||||
const degraded = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
|
||||
expect(degraded).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" });
|
||||
@@ -1383,79 +1383,14 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
expect(enforcer.getManualCompactionState("codex@rig")?.stage).toBe("skipped-or-failed");
|
||||
});
|
||||
|
||||
it("no known usage sample: an explicit manual request still prepares and compacts; unknown usage isn't a reason to stop", async () => {
|
||||
it("no known usage sample → rejected with no_usage_data (never triggers blind)", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000 });
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
|
||||
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
|
||||
const texts = send.mock.calls.map((call) => String(call[1]));
|
||||
expect(texts[0]).toContain("OpenRig manual compaction was requested for this seat");
|
||||
expect(texts[0]).not.toContain("null");
|
||||
expect(texts[1]).toMatch(/^\/compact /);
|
||||
});
|
||||
|
||||
it("the preparation request asks for a turn free of approval prompts: file-edit writes and a literal recap-write path", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000 });
|
||||
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(String(send.mock.calls[0]![1])).toContain("To avoid one cause of approval prompts, which hold /compact until someone answers them: write files with your file-edit tool (acceptEdits covers your working directory, not a preparation root outside it), and if you publish a seat recap, run rig context recap-write --file with the file's literal absolute path, with no shell variable assignment or command substitution, so an allow rule for rig commands can match it.");
|
||||
});
|
||||
|
||||
it("when the idle wait and the /compact send disagree, each round pauses a reading interval, bounded by the deadline", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
// The idle wait reads idle (a hook), while every /compact finds the pane at a prompt and writes nothing.
|
||||
send.mockImplementation(async (_s: string, text: string) => text.startsWith("/compact")
|
||||
? { ok: false, sent: false, inputPhase: "not_reached", reason: "target_needs_input", activity: { state: "needs_input" } }
|
||||
: { ok: true });
|
||||
let clock = 1_700_000_000_000;
|
||||
const sleeps: number[] = [];
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { manualPrepWaitMs: 20_000, now: () => clock,
|
||||
sleep: async (ms) => { sleeps.push(ms); clock += ms; } });
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(outcome).toMatchObject({ triggered: false, reason: "preparation_needs_input" });
|
||||
const compacts = send.mock.calls.filter((call) => String(call[1]).startsWith("/compact")).length;
|
||||
expect(compacts).toBeGreaterThan(1);
|
||||
expect(compacts).toBeLessThanOrEqual(20_000 / MANUAL_SEAT_READ_INTERVAL_MS + 1);
|
||||
// Every deferred send is followed by a pause, the last one running into the deadline.
|
||||
expect(sleeps.filter((ms) => ms === MANUAL_SEAT_READ_INTERVAL_MS).length).toBe(compacts);
|
||||
});
|
||||
|
||||
it("a /compact refused before input because the seat reached a prompt waits again within the deadline, then compacts", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
send.mockImplementationOnce(async () => ({ ok: true })); // the preparation request lands
|
||||
// The seat read idle, then its preparation turn reached a permission prompt before /compact: refused, nothing written.
|
||||
send.mockImplementationOnce(async () => ({ ok: false, sent: false, inputPhase: "not_reached", reason: "target_needs_input" }));
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000, sleep: async () => {} });
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
|
||||
const texts = send.mock.calls.map((call) => String(call[1]));
|
||||
expect(texts.filter((text) => text.startsWith("/compact"))).toHaveLength(2);
|
||||
expect(enforcer.getPreparationState(SEAT)).toMatchObject({ status: "compact-sent" });
|
||||
});
|
||||
|
||||
it("a seat still at a prompt when the deadline passes stops naming the prompt, and --state showed what it waited on", async () => {
|
||||
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
|
||||
const { transport, send } = makeSessionTransport();
|
||||
let clock = 1_700_000_000_000;
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { manualPrepWaitMs: 1000, now: () => clock, sleep: async (ms) => { clock += ms; } });
|
||||
const seen: unknown[] = [];
|
||||
transport.waitUntilIdle = async () => {
|
||||
seen.push(enforcer.getPreparationState(SEAT)?.awaitingInput);
|
||||
return { ok: false, reason: "target_needs_input", error: "fixture", activity: { state: "needs_input" } as any, waitedMs: 0, attempts: 1 } as any;
|
||||
};
|
||||
|
||||
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(outcome).toMatchObject({ triggered: false, stage: "skipped-or-failed", reason: "preparation_needs_input",
|
||||
preparation: { delivery: "delivered", compact: "not_sent" } });
|
||||
expect(seen.slice(1)).toContainEqual({ since: expect.any(Number), reason: "target_needs_input", observedAt: expect.any(Number), current: true });
|
||||
expect(send.mock.calls.filter((call) => String(call[1]).startsWith("/compact"))).toHaveLength(0);
|
||||
expect(outcome).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "no_usage_data" });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounded to the triggered seat: only that seat is sent to; no other seat gets manual state", async () => {
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import type { CodexFilesystemPermissions } from "../src/domain/codex-member-permissions.js";
|
||||
import { decideCodexFilesystemPolicy, verifyCodexFilesystemPolicy } from "../src/domain/codex-filesystem-preflight.js";
|
||||
import { readCodexConfiguration } from "../src/domain/codex-network-default.js";
|
||||
|
||||
vi.mock("../src/domain/codex-network-default.js", () => ({ readCodexConfiguration: vi.fn() }));
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
const permissions: CodexFilesystemPermissions = {
|
||||
approval: "never", filesystem: { ":minimal": "read", "/work": "write", "/work/hidden": "deny" }, network_access: false,
|
||||
};
|
||||
function config() {
|
||||
return { config: { approval_policy: "never", default_permissions: "openrig-seat", sandbox_mode: null,
|
||||
sandbox_workspace_write: null, profile: null, permissions: { "openrig-seat": {
|
||||
description: null, extends: null, workspace_roots: null,
|
||||
filesystem: { "/work/hidden": "deny", "/work": "write", ":minimal": "read", glob_scan_max_depth: null },
|
||||
network: { enabled: false, allow_local_binding: null, allow_upstream_proxy: null,
|
||||
dangerously_allow_all_unix_sockets: null, dangerously_allow_non_loopback_proxy: null, domains: null,
|
||||
enable_socks5: null, enable_socks5_udp: null, mitm: null, mode: null, proxy_url: null, socks_url: null, unix_sockets: null },
|
||||
} } }, origins: {}, layers: [] };
|
||||
}
|
||||
function decide(configRead: unknown = config(), requirementsRead: unknown = { requirements: null }) {
|
||||
return decideCodexFilesystemPolicy({ permissions, configRead, requirementsRead });
|
||||
}
|
||||
|
||||
describe("Codex seat filesystem configuration preflight", () => {
|
||||
it("accepts the exact authored policy regardless of mapping key order", () => {
|
||||
expect(decide()).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts the native nullable managed-policy response without overriding requirements", () => {
|
||||
expect(decide(config(), { requirements: { allowedApprovalPolicies: null, allowedApprovalsReviewers: null,
|
||||
allowedSandboxModes: null, allowedPermissionProfiles: null, defaultPermissions: null,
|
||||
featureRequirements: null, application: null, autoReview: null, network: null,
|
||||
futureFilesystemPolicy: null } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("allows other permission profiles in the shared home without selecting them", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "other-seat": { filesystem: { ":root": "write" } } });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["lower-layer extra write", { filesystem: { ...permissions.filesystem, "/poison": "write" }, network: { enabled: false } }],
|
||||
["changed deny", { filesystem: { ...permissions.filesystem, "/work/hidden": "read" }, network: { enabled: false } }],
|
||||
["missing deny", { filesystem: { ":minimal": "read", "/work": "write" }, network: { enabled: false } }],
|
||||
["network changed", { filesystem: permissions.filesystem, network: { enabled: true } }],
|
||||
["empty filesystem", { filesystem: {}, network: { enabled: false } }],
|
||||
])("refuses %s without disclosing its values", (_label, profile) => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": profile });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.any(String) });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wrong profile selection", { default_permissions: "other" }],
|
||||
["wrong approval", { approval_policy: "on-request" }],
|
||||
["missing profile map", { permissions: null }],
|
||||
])("refuses %s", (_label, patch) => {
|
||||
const read = config();
|
||||
Object.assign(read.config, patch);
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([undefined, null, {}, { config: [] }])("reports missing or malformed configuration %#", read => {
|
||||
expect(decideCodexFilesystemPolicy({ permissions, configRead: read, requirementsRead: { requirements: null } })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("accepts unrelated managed model settings and an approval allowlist that includes the choice", () => {
|
||||
expect(decide(config(), { requirements: { modelProvider: "openai", allowedApprovalPolicies: ["on-request", "never"],
|
||||
allowedPermissionProfiles: null, defaultPermissions: null, network: null, featureRequirements: null } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ allowedApprovalPolicies: ["on-request"] },
|
||||
{ allowedPermissionProfiles: { "other": true } },
|
||||
{ allowedPermissionProfiles: { "openrig-seat": false } },
|
||||
])("does not bypass managed policy %#", requirements => {
|
||||
expect(decide(config(), { requirements })).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([undefined, null, {}, { requirements: [] }, { requirements: null, futurePolicy: true }])("reports unreadable requirements %#", read => {
|
||||
expect(decideCodexFilesystemPolicy({ permissions, configRead: config(), requirementsRead: read })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("authored ancestor and selector coverage matrix", () => {
|
||||
const ancestors = {
|
||||
"root read": { "/": "read" },
|
||||
"root write": { "/": "write" },
|
||||
"root deny": { "/": "deny" },
|
||||
"native root deny": { ":root": "deny" },
|
||||
"literal read": { ":root": "read", "/work/hidden": "read" },
|
||||
"literal write": { ":root": "read", "/work/hidden": "write" },
|
||||
"literal deny": { ":root": "read", "/work": "write", "/work/hidden": "deny" },
|
||||
} satisfies Record<string, CodexFilesystemPermissions["filesystem"]>;
|
||||
type Outcome = "accept" | "not-verified" | "refuse";
|
||||
// Expected outcomes are authored from the rule, not computed by another
|
||||
// coverage comparator. Columns: read, write, read+aliases, write+aliases.
|
||||
// Authored uncertainty warns; lower-layer aliases cannot hide a known
|
||||
// widening. A non-root deny wins; a root deny is a refinable default.
|
||||
const matrix = [
|
||||
["root read", "none", "accept", "refuse", "not-verified", "refuse"],
|
||||
["root read", "read", "accept", "refuse", "not-verified", "refuse"],
|
||||
["root read", "write", "accept", "not-verified", "not-verified", "not-verified"],
|
||||
["root write", "none", "accept", "accept", "not-verified", "not-verified"],
|
||||
["root write", "read", "accept", "accept", "not-verified", "not-verified"],
|
||||
["root write", "write", "accept", "accept", "not-verified", "not-verified"],
|
||||
["root deny", "none", "refuse", "refuse", "refuse", "refuse"],
|
||||
["root deny", "read", "not-verified", "refuse", "not-verified", "refuse"],
|
||||
["root deny", "write", "not-verified", "not-verified", "not-verified", "not-verified"],
|
||||
["native root deny", "none", "refuse", "refuse", "refuse", "refuse"],
|
||||
["native root deny", "read", "not-verified", "refuse", "not-verified", "refuse"],
|
||||
["native root deny", "write", "not-verified", "not-verified", "not-verified", "not-verified"],
|
||||
["literal read", "none", "accept", "refuse", "not-verified", "refuse"],
|
||||
["literal read", "read", "accept", "refuse", "not-verified", "refuse"],
|
||||
["literal read", "write", "accept", "not-verified", "not-verified", "not-verified"],
|
||||
["literal write", "none", "accept", "accept", "not-verified", "not-verified"],
|
||||
["literal write", "read", "accept", "accept", "not-verified", "not-verified"],
|
||||
["literal write", "write", "accept", "accept", "not-verified", "not-verified"],
|
||||
["literal deny", "none", "refuse", "refuse", "refuse", "refuse"],
|
||||
["literal deny", "read", "refuse", "refuse", "refuse", "refuse"],
|
||||
["literal deny", "write", "refuse", "refuse", "refuse", "refuse"],
|
||||
] satisfies [keyof typeof ancestors, "none" | "read" | "write", Outcome, Outcome, Outcome, Outcome][];
|
||||
const cases = matrix.flatMap(([ancestor, selector, read, write, readAliases, writeAliases]) => [
|
||||
{ ancestor, selector, grant: "read", aliases: false, outcome: read },
|
||||
{ ancestor, selector, grant: "write", aliases: false, outcome: write },
|
||||
{ ancestor, selector, grant: "read", aliases: true, outcome: readAliases },
|
||||
{ ancestor, selector, grant: "write", aliases: true, outcome: writeAliases },
|
||||
]);
|
||||
it.each(cases)("$ancestor; selector=$selector; grant=$grant; aliases=$aliases => $outcome", ({ ancestor, selector, grant, aliases, outcome }) => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: {
|
||||
...ancestors[ancestor], ...(selector === "none" ? {} : { ":workspace_roots": selector }),
|
||||
} };
|
||||
const aliasAccess = selector === "none" ? "read" : selector;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: {
|
||||
...own.filesystem, ...(aliases ? { ":workspace_roots": aliasAccess, ":project_roots": aliasAccess } : {}),
|
||||
"/work/hidden/secret": grant,
|
||||
}, network: { enabled: false } } });
|
||||
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
|
||||
if (outcome === "refuse") expect(result).toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
|
||||
else if (outcome === "not-verified") expect(result).toMatchObject({ ok: true, verified: false });
|
||||
else expect(result).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("root deny refinements", () => {
|
||||
it.each(["/", ":root"])('keeps selector overlap unverified beneath %s deny', root => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":slash_tmp": "write" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/", "/Users/x/.ssh/key"], [":root", "/Users/x/.ssh/key"],
|
||||
["/", "/home/x/.ssh/key"], [":root", "/home/x/.ssh/key"],
|
||||
])('refuses a user-data read outside minimal defaults beneath %s deny at %s', (root, key) => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read", "/work": "write" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, [key]: "read" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining(JSON.stringify(key)) });
|
||||
});
|
||||
|
||||
it.each(["/", ":root"])('does not claim to expand minimal system reads beneath %s deny', root => {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/usr/bin/tool": "read" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each(["/", ":root"])('retains other authored coverage for user data beneath %s deny', root => {
|
||||
const key = "/Users/x/.ssh/key";
|
||||
for (const [rule, expected] of [[":workspace_roots", "not-verified"], ["/Users/x", "accept"]] as const) {
|
||||
const own: CodexFilesystemPermissions = { ...permissions, filesystem: { [root]: "deny", ":minimal": "read", [rule]: "read" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, [key]: "read" }, network: { enabled: false } } });
|
||||
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
|
||||
if (expected === "accept") expect(result).toEqual({ ok: true, verified: true });
|
||||
else expect(result).toMatchObject({ ok: true, verified: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("minimal coverage without an authored root rule", () => {
|
||||
it.each(["/Users/x/.ssh/key", "/users/x/.ssh/key", "/HOME/x/.ssh/key"])("refuses an uncovered user-data read at %s", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "read" });
|
||||
const result = decide(read);
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining(JSON.stringify(key)) });
|
||||
if (!result.ok) expect(result.error).toContain("Fix:");
|
||||
});
|
||||
|
||||
it("keeps an unexpanded system read unverified and names its key", () => {
|
||||
const key = "/opt/x";
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "read" });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining(JSON.stringify(key)) });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex seat filesystem verifier reader boundary", () => {
|
||||
it.each([["env", "CODEX_HOME=/other"], ["custom-wrapper"]])("reports a launch wrapper without certifying a different configuration %#", async launchWrapper => {
|
||||
vi.mocked(readCodexConfiguration).mockClear();
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", launchWrapper }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("launch_wrapper") });
|
||||
expect(readCodexConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["", "relative/home", "/absolute\nother"])("reports an unestablished account home %# before reading", async codexHome => {
|
||||
vi.mocked(readCodexConfiguration).mockClear();
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome })).toMatchObject({ ok: true, verified: false });
|
||||
expect(readCodexConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the seat home and candidate invocation policy, never sandbox or per-command selection overrides", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: true, configRead: config(), requirementsRead: { requirements: null } });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", launchPath: "/bin" })).toEqual({ ok: true, verified: true });
|
||||
expect(readCodexConfiguration).toHaveBeenLastCalledWith({ cwd: "/work", codexHome: "/account", launchPath: "/bin", args: [
|
||||
"-c", 'approval_policy="never"', "-c", 'default_permissions="openrig-seat"', "-c",
|
||||
'permissions={"openrig-seat"={filesystem={":minimal"="read","/work"="write","/work/hidden"="deny"},network={enabled=false}}}',
|
||||
"-c", "features.plugins=false", "-c", "features.apps=false",
|
||||
] });
|
||||
});
|
||||
|
||||
it("returns an unavailable read as unverified", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: false, reason: "native read timed out" });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("names the filesystem rule, access on each side and a remedy without dumping configuration", () => {
|
||||
const read = config();
|
||||
read.config.permissions["openrig-seat"].filesystem["/work/hidden"] = "read";
|
||||
Object.assign(read.config, { privateSetting: "do-not-print-this-value" });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringMatching(/\/work\/hidden.*agent.*deny.*(?:Codex|layer).*read/i) });
|
||||
const decision = decide(read);
|
||||
if (decision.ok) throw new Error("fixture must conflict");
|
||||
expect(decision.error).toContain("\nFix:");
|
||||
expect(decision.error).not.toContain("do-not-print-this-value");
|
||||
read.config.default_permissions = "private-profile-selection";
|
||||
const selection = decide(read);
|
||||
if (selection.ok) throw new Error("fixture must conflict");
|
||||
expect(selection.error).toContain("default_permissions");
|
||||
expect(selection.error).toContain("\nFix:");
|
||||
expect(selection.error).not.toContain("private-profile-selection");
|
||||
});
|
||||
|
||||
it("returns a thrown reader failure without exposing arbitrary error contents", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockRejectedValueOnce(new Error("do not print this"));
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" })).toEqual({
|
||||
ok: true, verified: false, reason: "Native Codex configuration reader failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the normal account home without an explicit CODEX_HOME", async () => {
|
||||
vi.stubEnv("CODEX_HOME", undefined);
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: true, configRead: config(), requirementsRead: { requirements: null } });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work" })).toEqual({ ok: true, verified: true });
|
||||
expect(readCodexConfiguration).toHaveBeenLastCalledWith(expect.objectContaining({ codexHome: `${os.homedir()}/.codex` }));
|
||||
});
|
||||
|
||||
it("keeps a named profile unverified instead of certifying the base configuration", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockClear();
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account", codexConfigProfile: "model-only" }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("codex_config_profile") });
|
||||
expect(readCodexConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compatible policies and inconclusive reads", () => {
|
||||
it.each([":root", ":minimal"])(
|
||||
"refuses a known extra write grant at %s, including beside an unfamiliar representation", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "write", future: { access: "future" } });
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("refuses full-root read beyond minimal read", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":root": "read" });
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([":workspace_roots", ":project_roots", ":tmpdir", ":slash_tmp", "~/x"])(
|
||||
"leaves extra %s coverage unverified when an authored path may already cover it", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { [key]: "write" });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("does not refuse a potentially redundant slash_tmp grant beside an authored /tmp grant", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", "/tmp": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":slash_tmp": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("still refuses a selector write when no authored rule could cover write access", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":slash_tmp": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("does not invent a selector conflict by collapsing ambiguous coverage aliases", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", ":project_roots": "write", ":workspace_roots": "deny" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ":tmpdir": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("effective configuration grants") });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ":workspace_roots": "read", ":project_roots": "read" },
|
||||
{ ":project_roots": "read", ":workspace_roots": "read" },
|
||||
])("refuses an outside write despite lower-layer duplicate aliases %#", aliases => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, ...aliases, "/outside": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining('"/outside"') });
|
||||
});
|
||||
|
||||
it("leaves only the lower-layer duplicate aliases unverified when there is no separate conflict", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem,
|
||||
":workspace_roots": "read", ":project_roots": "read" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("leaves an extra absolute rule unverified when a native selector may cover it", () => {
|
||||
const own = { ...permissions, filesystem: { ":root": "read", ":tmpdir": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false });
|
||||
const literal = { ...permissions, filesystem: { ":root": "read", "/work": "write" } } satisfies CodexFilesystemPermissions;
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...literal.filesystem, "/outside": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: literal, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("checks authored selectors before refusing a write beneath a read-only literal ancestor", () => {
|
||||
const own = { ...permissions, filesystem: { "/": "read", ":slash_tmp": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/tmp/child": "write" }, network: { enabled: false } } });
|
||||
const result = decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } });
|
||||
expect(result).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining('"/tmp/child"') });
|
||||
expect(result).toMatchObject({ reason: expect.stringContaining("write") });
|
||||
});
|
||||
|
||||
it("still refuses a direct literal-root widening beside an authored selector", () => {
|
||||
const own = { ...permissions, filesystem: { "/": "read", ":slash_tmp": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining('"/"') });
|
||||
});
|
||||
|
||||
it("refuses a read beneath the fixture's authored deny despite minimal read coverage", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work/hidden/secret": "read" });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
|
||||
});
|
||||
|
||||
it("refuses a write beneath an authored deny despite an authored write selector", () => {
|
||||
const own = { ...permissions, filesystem: { ...permissions.filesystem, ":workspace_roots": "write" } } satisfies CodexFilesystemPermissions;
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ...own.filesystem, "/work/hidden/secret": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: own, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining('"/work/hidden/secret"') });
|
||||
});
|
||||
|
||||
it("names the extra selector and access in an inconclusive coverage warning", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":slash_tmp": "write" });
|
||||
const result = decide(read);
|
||||
expect(result).toMatchObject({ ok: true, verified: false, reason: expect.stringContaining('":slash_tmp"') });
|
||||
expect(result).toMatchObject({ reason: expect.stringContaining("write") });
|
||||
});
|
||||
|
||||
it.each(["never", "on-request"] satisfies CodexFilesystemPermissions["approval"][])(
|
||||
"reports an unfamiliar approval string as unverified for authored %s", approval => {
|
||||
const read = config();
|
||||
read.config.approval_policy = "future-native-approval";
|
||||
expect(decideCodexFilesystemPolicy({ permissions: { ...permissions, approval }, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("approval") });
|
||||
});
|
||||
|
||||
it("still refuses a recognized untrusted approval instead of treating it as a future spelling", () => {
|
||||
const read = config();
|
||||
read.config.approval_policy = "untrusted";
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining("approval_policy") });
|
||||
});
|
||||
|
||||
it("does not let an unfamiliar approval hide a known filesystem or network conflict", () => {
|
||||
const read = config();
|
||||
read.config.approval_policy = "future-native-approval";
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/outside": "write" });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining('"/outside"') });
|
||||
Object.assign(read.config.permissions["openrig-seat"], { filesystem: permissions.filesystem, network: { enabled: true } });
|
||||
expect(decide(read)).toMatchObject({ ok: false, error: expect.stringContaining("network.enabled") });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ allowedApprovalPolicies: ["on-request"] },
|
||||
{ allowedPermissionProfiles: { "openrig-seat": false } },
|
||||
])("reports an excluding managed allowlist ahead of effective selection or approval mismatches %#", requirements => {
|
||||
const read = config();
|
||||
Object.assign(read.config, { default_permissions: "other", approval_policy: "on-request" });
|
||||
expect(decide(read, { requirements })).toMatchObject({ ok: false, error: expect.stringContaining("managed allowlist") });
|
||||
read.config.default_permissions = "openrig-seat";
|
||||
expect(decide(read, { requirements })).toMatchObject({ ok: false, error: expect.stringContaining("managed allowlist") });
|
||||
});
|
||||
|
||||
it.each(["default_permissions", "approval_policy"])("reports absent or null %s without inventing a conflict", key => {
|
||||
for (const value of [undefined, null]) {
|
||||
const read = config();
|
||||
Object.assign(read.config, { [key]: value });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a new profile representation without a filesystem field but keeps independent conflicts", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { futureFilesystem: { access: "new-format" }, network: { enabled: false } } });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
read.config.permissions["openrig-seat"].network.enabled = true;
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("accepts an allowed effective selection even when the managed fallback selects another profile", () => {
|
||||
expect(decide(config(), { requirements: { defaultPermissions: "private-fallback-value",
|
||||
allowedPermissionProfiles: { "openrig-seat": true } } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts a known selector grant already covered by an authored full-root grant", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { ":root": "write", ":workspace_roots": "write", "~/x": "read" });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: { ...permissions, filesystem: { ...permissions.filesystem, ":root": "write" } },
|
||||
configRead: read, requirementsRead: { requirements: null } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it.each(["default_permissions", "approval_policy"])("reports a future %s representation without inventing a conflict", key => {
|
||||
const read = config();
|
||||
Object.assign(read.config, { [key]: { selection: "future-native-format" } });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("refuses an explicitly excluded profile even beside unknown allowlist entries", () => {
|
||||
expect(decide(config(), { requirements: { allowedPermissionProfiles: { "openrig-seat": false, future: { allowed: true } } } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false }, extends: ":workspace" },
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false }, workspace_roots: { "/extra": true } },
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false }, futurePolicy: true },
|
||||
{ filesystem: permissions.filesystem, network: { enabled: false, allow_local_binding: true } },
|
||||
{ filesystem: { ":workspace_roots": { ".": "write" } }, network: { enabled: false } },
|
||||
{ filesystem: { "/work": "allow" }, network: { enabled: false } },
|
||||
])("reports unfamiliar selected policy options %#", profile => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": profile });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ allowedApprovalPolicies: "never" }, { featureRequirements: { use_linux_sandbox_bwrap: false } },
|
||||
{ network: {} }, { futureFilesystemPolicy: true },
|
||||
])("reports unsupported requirements without refusing %#", requirements => {
|
||||
expect(decide(config(), { requirements })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("keeps positive configuration and managed conflicts visible alongside unknowns", () => {
|
||||
const read = config();
|
||||
read.config.default_permissions = "other";
|
||||
expect(decide(read, { requirements: { futureManagedPolicy: true } })).toMatchObject({ ok: false });
|
||||
expect(decide(config(), { requirements: { futureManagedPolicy: true, allowedApprovalPolicies: ["on-request"] } }))
|
||||
.toMatchObject({ ok: false });
|
||||
expect(decideCodexFilesystemPolicy({ permissions, configRead: null, requirementsRead: { requirements: { allowedPermissionProfiles: { "openrig-seat": false } } } }))
|
||||
.toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("accepts inert legacy settings, a model-only profile and harmless descriptions", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config, { sandbox_mode: "read-only", sandbox_workspace_write: { network_access: true }, profile: "model-only" });
|
||||
Object.assign(read.config.permissions["openrig-seat"], { description: "description only" });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("treats every null profile field as absent, including future fields", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"], { futureProfile: null });
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { futureFilesystem: null });
|
||||
Object.assign(read.config.permissions["openrig-seat"].network, { futureNetwork: null });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts a narrower filesystem policy and redundant subpath grants", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work": "read", "/work/child": "read" });
|
||||
expect(decide(read)).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("accepts matching managed selection and allowlists", () => {
|
||||
expect(decide(config(), { requirements: { defaultPermissions: "openrig-seat",
|
||||
allowedPermissionProfiles: { "openrig-seat": true }, allowedApprovalPolicies: ["on-request", "never"],
|
||||
allowedSandboxModes: ["read-only"] } })).toEqual({ ok: true, verified: true });
|
||||
});
|
||||
|
||||
it("compares the project_roots alias without silently collapsing conflicting spellings", () => {
|
||||
const withAlias: CodexFilesystemPermissions = { approval: "never", filesystem: { ":project_roots": "write" } };
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions, { "openrig-seat": { filesystem: { ":workspace_roots": "write" }, network: { enabled: false } } });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: withAlias, configRead: read, requirementsRead: { requirements: null } }))
|
||||
.toEqual({ ok: true, verified: true });
|
||||
expect(decideCodexFilesystemPolicy({ permissions: { ...withAlias, filesystem: { ":workspace_roots": "deny", ":project_roots": "write" } },
|
||||
configRead: read, requirementsRead: { requirements: null } })).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ configRead: null, requirementsRead: { requirements: null } },
|
||||
{ configRead: config(), requirementsRead: { requirements: [] } },
|
||||
{ configRead: config(), requirementsRead: { requirements: { futureManagedPolicy: true } } },
|
||||
])("reports an unreadable shape or unknown requirement without refusing %#", request => {
|
||||
expect(decideCodexFilesystemPolicy({ permissions, ...request })).toMatchObject({ ok: true, verified: false, reason: expect.any(String) });
|
||||
});
|
||||
|
||||
it.each(["future-access", { read: ["/nested"] }])("reports unfamiliar filesystem representation without refusing %j", access => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/work/hidden": access });
|
||||
expect(decide(read)).toMatchObject({ ok: true, verified: false });
|
||||
});
|
||||
|
||||
it("still refuses a concrete extra write grant alongside an unfamiliar representation", () => {
|
||||
const read = config();
|
||||
Object.assign(read.config.permissions["openrig-seat"].filesystem, { "/poison": "write", "/future": { read: ["/nested"] } });
|
||||
expect(decide(read)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("reports a reader timeout without dropping or replacing the invocation policy", async () => {
|
||||
vi.mocked(readCodexConfiguration).mockResolvedValueOnce({ ok: false, reason: "native read timed out" });
|
||||
expect(await verifyCodexFilesystemPolicy({ permissions, cwd: "/work", codexHome: "/account" }))
|
||||
.toMatchObject({ ok: true, verified: false, reason: expect.stringContaining("timed out") });
|
||||
});
|
||||
});
|
||||
@@ -82,71 +82,6 @@ describe("the typed Codex envelope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a seat's explicit filesystem policy", () => {
|
||||
it("accepts the native project_roots alias without changing its authored spelling", () => {
|
||||
expect(validateCodexMemberPermissions({ approval: "never", filesystem: { ":project_roots": "write" } }))
|
||||
.toEqual({ approval: "never", filesystem: { ":project_roots": "write" }, network_access: false });
|
||||
});
|
||||
const policy = { approval: "never", filesystem: {
|
||||
":minimal": "read", ":workspace_roots": "read", "/lease/a": "write", "/lease/a/private": "deny",
|
||||
}, network_access: false };
|
||||
|
||||
it("accepts native read, write and deny entries without a legacy sandbox selection", () => {
|
||||
expect(validateCodexMemberPermissions(policy)).toEqual(policy);
|
||||
expect(validateCodexMemberPermissions({ approval: "on-request", filesystem: { ":minimal": "read" } }))
|
||||
.toEqual({ approval: "on-request", filesystem: { ":minimal": "read" }, network_access: false });
|
||||
});
|
||||
|
||||
it("keeps two seats' filesystem choices separate and clears only the selected seat", async () => {
|
||||
const f = fixture();
|
||||
const other = { approval: "on-request", filesystem: { ":minimal": "read", "/lease/b": "write" }, network_access: false };
|
||||
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true, to: policy });
|
||||
expect(await f.service.setPermissions({ ...set(other), seatRef: "dev-b@codex-team" })).toMatchObject({ ok: true, to: other });
|
||||
const store = new NativePermissionStore(f.db);
|
||||
expect(store.launchOverride(f.a.id, "codex").codexPermissions).toEqual(policy);
|
||||
expect(store.launchOverride(f.b.id, "codex").codexPermissions).toEqual(other);
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", mode: "inherit", actor: "operator@codex-team", reason: "Clear only A" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toBeUndefined();
|
||||
expect(store.launchOverride(f.b.id, "codex").codexPermissions).toEqual(other);
|
||||
});
|
||||
|
||||
it("allows named-profile combinations in either setter direction and keeps the authored policy", async () => {
|
||||
const f = fixture();
|
||||
await f.service.setPermissions(set(reader));
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "named", actor: "operator@codex-team", reason: "Select" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(policy);
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "inherit", actor: "operator@codex-team", reason: "Clear" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions(set(policy))).toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions({ seatRef: "dev-a@codex-team", codexProfile: "named", actor: "operator@codex-team", reason: "Conflict" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(policy);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...policy, approval: "untrusted" },
|
||||
{ ...policy, sandbox: "workspace-write" },
|
||||
{ ...policy, writable_roots: ["/another/root"] },
|
||||
{ ...policy, exclude_slash_tmp: true },
|
||||
{ ...policy, filesystem: {} },
|
||||
{ ...policy, filesystem: { "relative/path": "deny" } },
|
||||
{ ...policy, filesystem: { ":unknown": "write" } },
|
||||
{ ...policy, filesystem: { "/a\nb": "deny" } },
|
||||
{ ...policy, filesystem: { "/private": "none" } },
|
||||
{ ...policy, filesystem: { "/private": true } },
|
||||
{ ...policy, network_access: "yes" },
|
||||
])("rejects incomplete or mixed policy without replacing a saved choice: %j", async (invalid) => {
|
||||
expect(() => validateCodexMemberPermissions(invalid)).toThrow();
|
||||
const f = fixture();
|
||||
expect(await f.service.setPermissions(set(reader))).toMatchObject({ ok: true });
|
||||
expect(await f.service.setPermissions(set(invalid))).toMatchObject({ ok: false });
|
||||
expect(readCodexMemberPermissions(f.db, f.a.id)).toEqual(reader);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a Codex seat's current envelope (offline, no native enforcement claim)", () => {
|
||||
it("gives two seats in one folder their own envelopes, as the seat's explicit choice", async () => {
|
||||
const f = fixture();
|
||||
@@ -314,7 +249,7 @@ describe("a Codex seat's current profile", () => {
|
||||
});
|
||||
|
||||
describe("both writers: the spec at creation, the setter afterwards, every launch reading the seat's record", () => {
|
||||
const authored = (profile: string | undefined, permissions?: CodexMemberPermissions) => ({ version: "0.2", name: "authored", pods: [{ id: "dev", label: "Dev", members: [
|
||||
const authored = (profile: string, permissions?: CodexMemberPermissions) => ({ version: "0.2", name: "authored", pods: [{ id: "dev", label: "Dev", members: [
|
||||
{ id: "impl", agent_ref: "local:impl.agent.yaml", profile: "default", runtime: "codex", cwd: "/same/project", codex_config_profile: profile,
|
||||
...(permissions ? { codex_permissions: permissions } : {}) },
|
||||
], edges: [] }], edges: [] });
|
||||
@@ -357,27 +292,6 @@ describe("both writers: the spec at creation, the setter afterwards, every launc
|
||||
expect(RigSpecSchema.validate(wrongRuntime).errors.join(" ")).toContain("codex_permissions");
|
||||
});
|
||||
|
||||
it("materializes and exports the filesystem choice with a named profile", async () => {
|
||||
const f = fixture();
|
||||
const policy: CodexMemberPermissions = { approval: "never", filesystem: { ":minimal": "read", "/lease/a": "write", "/lease/a/private": "deny" }, network_access: false };
|
||||
const raw = authored("model-only", policy);
|
||||
expect(RigSpecSchema.validate(raw).valid).toBe(true);
|
||||
const { setup, node, rigId } = await materialize(f.db, raw);
|
||||
expect(readCodexMemberPermissions(f.db, node.id)).toEqual(policy);
|
||||
const exported = new RigSpecExporter({ rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, podRepo: new PodRepository(f.db) }).exportRig(rigId);
|
||||
const decoded = RigSpecSchema.normalize(RigSpecCodec.parse(RigSpecCodec.serialize(exported as import("../src/domain/types.js").RigSpec)));
|
||||
expect(decoded.pods[0]!.members[0]!.codexPermissions).toEqual(policy);
|
||||
expect(decoded.pods[0]!.members[0]!.codexConfigProfile).toBe("model-only");
|
||||
setup.sessionRegistry.registerSession(node.id, "dev-impl@authored");
|
||||
const service = new SeatLifecycleService({ db: f.db, rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, eventBus: setup.eventBus,
|
||||
tmuxAdapter: {} as TmuxAdapter });
|
||||
expect(await service.setPermissions({ seatRef: "dev-impl@authored", codexProfile: "other-model", actor: "operator@authored", reason: "Model selection" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(await service.setPermissions({ seatRef: "dev-impl@authored", codexPermissions: policy, actor: "operator@authored", reason: "Keep policy" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(readCodexMemberPermissions(f.db, node.id)).toEqual(policy);
|
||||
});
|
||||
|
||||
it("restores with the seat's current profile, so an older snapshot can't bring back a replaced one", async () => {
|
||||
const f = fixture();
|
||||
const setup = createTestApp(f.db);
|
||||
@@ -408,8 +322,6 @@ describe("legacy flat specs carry the envelope too", () => {
|
||||
const exported = new RigSpecExporter({ rigRepo: f.rigRepo, sessionRegistry: new SessionRegistry(f.db) }).exportRig(f.a.rigId);
|
||||
const parsed = LegacyRigSpecCodec.parse(LegacyRigSpecCodec.serialize(exported as import("../src/domain/types.js").LegacyRigSpec));
|
||||
expect(LegacyRigSpecSchema.normalize(parsed).nodes.find(n => n.codexPermissions)!.codexPermissions).toEqual(lease);
|
||||
expect(LegacyRigSpecSchema.validate({ ...raw, nodes: [{ ...raw.nodes[0], codex_config_profile: "model-only",
|
||||
codex_permissions: { approval: "never", filesystem: { ":project_roots": "write" } } }] }).errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -138,28 +138,6 @@ function makeValidFlagPolicy(posture: "floor" | "full_bypass"): string {
|
||||
}
|
||||
|
||||
describe("Issue #2989 — native-resume-probe Codex show-and-label builder", () => {
|
||||
describe.each(["floor", "full_bypass", "auto"] as const)("filesystem policy with %s posture fallback", (resolvedPosture) => {
|
||||
it.each([false, true])("labels the omitted policy with recorded launch=%s, including wrappers", (hasRecord) => {
|
||||
const display = buildCodexResumeDisplay("tok-filesystem", {
|
||||
codexPermissions: { filesystem: { ":root": "read", "/work": "write" }, approval: "never" },
|
||||
resolvedPosture,
|
||||
recordedLaunch: hasRecord ? {
|
||||
runtime: "codex", axis: "sandbox", state: "observed", value: "permissions:openrig-seat",
|
||||
approvalPolicy: "never", reason: "emitted_launch_arguments",
|
||||
} : null,
|
||||
launchWrapper: ["env", "POLICY_WRAPPER=1"],
|
||||
});
|
||||
const fallback = buildCodexResumeDisplay("tok-filesystem", { resolvedPosture });
|
||||
expect(display.command).toBe(fallback.command);
|
||||
expect(display.command).not.toContain("default_permissions");
|
||||
expect(display.label).toContain("shown command uses posture fallback");
|
||||
expect(display.label).toContain("does not carry the agent's filesystem policy");
|
||||
expect(display.label).toContain("omits launch_wrapper");
|
||||
expect(display.label).not.toContain("a resume now uses");
|
||||
expect(display.status).toBe(hasRecord ? "recorded_uncertain" : "no_recorded_launch");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -7,9 +7,6 @@ import Database from "better-sqlite3";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
|
||||
import type { CodexFilesystemVerifier } from "../src/domain/codex-filesystem-preflight.js";
|
||||
import { ObservationPass } from "../src/domain/process-observation-pass.js";
|
||||
import { codexNonLoginShellArg } from "../src/domain/native-resume-probe.js";
|
||||
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
|
||||
@@ -2845,203 +2842,6 @@ describe("a seat's own permission envelope (argv only, not native enforcement)",
|
||||
});
|
||||
});
|
||||
|
||||
describe("a seat's explicit filesystem policy (argv and native-read preflight, not enforcement)", () => {
|
||||
const filesystemChoice = { approval: "never", filesystem: { ":minimal": "read", "/same": "write", "/same/private": "deny" }, network_access: false } satisfies import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions;
|
||||
|
||||
it.each(["fresh", "resume", "codex_id", "codex_last"])("keeps a wrapped filesystem launch and reports unverified policy on %s", async kind => {
|
||||
const tmux = mockTmux();
|
||||
const environment = new SeatLaunchEnvironment(tmux, { PATH: "/bin" }, "/same");
|
||||
const command = vi.spyOn(environment, "command").mockImplementation(async (_session, core) => core);
|
||||
const verifyFilesystemPolicy: CodexFilesystemVerifier = async request => request.launchWrapper?.length
|
||||
? { ok: true, verified: false, reason: "launch_wrapper can change the checked configuration" } : { ok: true, verified: true };
|
||||
const launchWrapper = ["env", "CODEX_HOME=/other"];
|
||||
const options = { codexHome: "/shared-home", verifyFilesystemPolicy, seatLaunchEnvironment: environment,
|
||||
sleep: async () => {} };
|
||||
if (kind === "fresh" || kind === "resume") {
|
||||
const adapter = new CodexRuntimeAdapter({ ...options, tmux, fsOps: mockFs() });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice, launchWrapper };
|
||||
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: true, appliedLaunch: { reason: expect.stringContaining("policy not verified: launch_wrapper") } });
|
||||
} else {
|
||||
const restore = new CodexResumeAdapter(tmux, { ...options, maxWaitMs: 0 });
|
||||
expect(await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "floor", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice, undefined, launchWrapper))
|
||||
.toMatchObject({ ok: true, appliedLaunch: { reason: expect.stringContaining("policy not verified: launch_wrapper") } });
|
||||
}
|
||||
expect(command).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('default_permissions="openrig-seat"'), expect.objectContaining({ launchWrapper }));
|
||||
});
|
||||
|
||||
it.each(["fresh", "resume"])("emits the selected policy on %s without extra writable roots or shared policy files", async path => {
|
||||
const tmux = mockTmux(), filesystem = mockFs();
|
||||
const writes = vi.spyOn(filesystem, "writeFile");
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: true }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: filesystem, sleep: async () => {}, verifyFilesystemPolicy,
|
||||
codexHome: "/shared-home", resolveGitAddDirs: async () => ["/same/.git"], prepareSeatFolder: () => "/seat-state" });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice, launchPosture: "full_bypass" as const };
|
||||
const result = path === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
const command = path === "fresh" ? vi.mocked(tmux.sendText).mock.calls[0]![1] : "core" in result ? result.core : "";
|
||||
expect(command).toContain('default_permissions="openrig-seat"');
|
||||
expect(command).toContain('"/same/private"="deny"');
|
||||
expect(command).not.toMatch(/--add-dir|danger-full-access|sandbox_workspace_write| -s /);
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ permissions: filesystemChoice, cwd: "/same", codexHome: "/shared-home" }));
|
||||
expect(writes.mock.calls.every(([path]) => !path.endsWith(".codex/config.toml"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a native-read mismatch before dispatching any command", async () => {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy: async () => ({ ok: false, error: "lower profile adds a writable root" }) });
|
||||
expect(await adapter.launchHarness({ ...makeBinding("/same"), codexPermissions: filesystemChoice }, { name: "dev-a@test-rig" }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining("lower profile") });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["fresh", "resume"])("pins the normal account home on the native read and %s command", async kind => {
|
||||
const previous = process.env.CODEX_HOME;
|
||||
delete process.env.CODEX_HOME;
|
||||
try {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: false, reason: "native read timed out" }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: { ...mockFs(), homedir: "/normal-account" }, sleep: async () => {}, verifyFilesystemPolicy });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice };
|
||||
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: true, appliedLaunch: { reason: "policy not verified: native read timed out" } });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexHome: "/normal-account/.codex" }));
|
||||
const command = kind === "fresh" ? vi.mocked(tmux.sendText).mock.calls[0]?.[1] : "command" in result ? result.command : "";
|
||||
expect(command).toContain("CODEX_HOME='/normal-account/.codex'");
|
||||
expect(command).toContain('default_permissions="openrig-seat"');
|
||||
expect(command).toContain('"/same/private"="deny"');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a thrown native reader as unverified before handover without changing config or sending input", async () => {
|
||||
const tmux = mockTmux(), filesystem = mockFs();
|
||||
const write = vi.spyOn(filesystem, "writeFile");
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: filesystem,
|
||||
verifyFilesystemPolicy: async () => { throw new Error("private error contents"); } });
|
||||
expect(await adapter.preflightLaunch({ ...makeBinding(), codexPermissions: filesystemChoice }))
|
||||
.toEqual({ ok: true, warnings: ["policy not verified: Native Codex configuration reader failed"] });
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries a pre-cutover check into one launch and checks again for a later launch", async () => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>()
|
||||
.mockResolvedValueOnce({ ok: true, verified: false, reason: "native read timed out" })
|
||||
.mockResolvedValueOnce({ ok: false, error: "later policy conflict" });
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, verifyFilesystemPolicy });
|
||||
const binding = { ...makeBinding("/same"), codexPermissions: filesystemChoice };
|
||||
expect(await adapter.preflightLaunch(binding)).toMatchObject({ ok: true });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
expect(await adapter.launchHarness(binding, { name: "dev-a@test-rig" }))
|
||||
.toMatchObject({ ok: true, warnings: ["policy not verified: native read timed out"],
|
||||
appliedLaunch: { reason: "policy not verified: native read timed out" } });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(1);
|
||||
vi.mocked(tmux.sendText).mockClear();
|
||||
expect(await adapter.launchHarness(binding, { name: "dev-a@test-rig" })).toMatchObject({ ok: false, error: "later policy conflict" });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(2);
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ homes: ["/shared-home", "/shared-home"], cwds: ["/same", "/same"] },
|
||||
{ homes: ["/shared-home", "/shared-home"], cwds: ["/project-a", "/project-b"] },
|
||||
{ homes: ["/home-a", "/home-b"], cwds: ["/same", "/same"] },
|
||||
] satisfies { homes: [string, string]; cwds: [string, string] }[])("keeps A/B/A policies separate with home/cwd sharing: %j", async ({ homes, cwds }) => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: true }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, verifyFilesystemPolicy,
|
||||
seatHome: id => id === "A" ? homes[0] : homes[1] });
|
||||
const other: import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions = {
|
||||
approval: "on-request", filesystem: { ":minimal": "read", "/lease/b": "write" }, network_access: false,
|
||||
};
|
||||
for (const { id, cwd, permissions } of [
|
||||
{ id: "A", cwd: cwds[0], permissions: filesystemChoice },
|
||||
{ id: "B", cwd: cwds[1], permissions: other },
|
||||
{ id: "A", cwd: cwds[0], permissions: filesystemChoice },
|
||||
]) {
|
||||
expect(await adapter.launchHarness({ ...makeBinding(cwd), nodeId: id,
|
||||
codexPermissions: permissions }, { name: `dev-${id}@test-rig` })).toMatchObject({ ok: true });
|
||||
}
|
||||
const commands = vi.mocked(tmux.sendText).mock.calls.map(call => call[1]);
|
||||
expect(commands).toHaveLength(3);
|
||||
expect(commands[0]).toContain('"/same/private"="deny"');
|
||||
expect(commands[0]).not.toContain('"/lease/b"');
|
||||
expect(commands[1]).toContain('"/lease/b"="write"');
|
||||
expect(commands[1]).not.toContain('"/same/private"');
|
||||
expect(commands[2]).toEqual(commands[0]);
|
||||
expect(verifyFilesystemPolicy.mock.calls.map(call => call[0])).toEqual([
|
||||
expect.objectContaining({ codexHome: homes[0], cwd: cwds[0], permissions: filesystemChoice }),
|
||||
expect.objectContaining({ codexHome: homes[1], cwd: cwds[1], permissions: other }),
|
||||
expect.objectContaining({ codexHome: homes[0], cwd: cwds[0], permissions: filesystemChoice }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a named configuration profile and the authored filesystem policy on resume", async () => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy = vi.fn<CodexFilesystemVerifier>(async () => ({ ok: true, verified: false, reason: "profile reader unavailable" }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy,
|
||||
verifyProfilePreflight: async profile => ({ ok: true, profile }) });
|
||||
expect(await adapter.buildResumeCommand({ ...makeBinding("/same"), codexPermissions: filesystemChoice, codexConfigProfile: "named" },
|
||||
{ name: "dev-a@test-rig", resumeToken: "thread-A" })).toMatchObject({ ok: true,
|
||||
core: expect.stringMatching(/-p 'named' .*default_permissions="openrig-seat"/), appliedLaunch: { reason: "policy not verified: profile reader unavailable" } });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexConfigProfile: "named" }));
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["codex_id", "codex_last"])("legacy restore %s carries the same policy and refuses a failed native read", async kind => {
|
||||
const tmux = mockTmux();
|
||||
const restore = new CodexResumeAdapter(tmux, { codexHome: "/shared-home", maxWaitMs: 0, sleep: async () => {},
|
||||
verifyFilesystemPolicy: async () => ({ ok: true, verified: true }), resolveGitAddDirs: async () => ["/same/.git"] });
|
||||
await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "full_bypass", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice);
|
||||
const command = vi.mocked(tmux.sendText).mock.calls[0]![1];
|
||||
expect(command).toContain('default_permissions="openrig-seat"');
|
||||
expect(command).toContain('"/same/private"="deny"');
|
||||
expect(command).not.toMatch(/--add-dir|danger-full-access|sandbox_workspace_write| -s /);
|
||||
vi.mocked(tmux.sendText).mockClear();
|
||||
const refusing = new CodexResumeAdapter(tmux, { verifyFilesystemPolicy: async () => ({ ok: false, error: "policy mismatch" }) });
|
||||
expect(await refusing.resume("dev-a@test-rig", kind, "thread-A", "/same", undefined, "floor", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice)).toMatchObject({ ok: false, message: "policy mismatch" });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["fresh", "resume", "codex_id", "codex_last"])("checks a filesystem seat's named profile load before dispatch on %s", async kind => {
|
||||
const tmux = mockTmux();
|
||||
const verifyFilesystemPolicy: CodexFilesystemVerifier = async () => ({ ok: true, verified: false, reason: "profile configuration cannot be compared" });
|
||||
if (kind === "fresh" || kind === "resume") {
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), verifyFilesystemPolicy,
|
||||
verifyProfilePreflight: async profile => ({ ok: false, profile, error: "profile failed to load" }) });
|
||||
const binding = { ...makeBinding(), codexPermissions: filesystemChoice, codexConfigProfile: "broken" };
|
||||
const result = kind === "fresh" ? await adapter.launchHarness(binding, { name: "dev-a@test-rig" })
|
||||
: await adapter.buildResumeCommand(binding, { name: "dev-a@test-rig", resumeToken: "thread-A" });
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("profile failed to load") });
|
||||
} else {
|
||||
const restore = new CodexResumeAdapter(tmux, { verifyFilesystemPolicy, exec: async () => { throw new Error("profile failed to load"); } });
|
||||
expect(await restore.resume("dev-a@test-rig", kind, "thread-A", "/same", "broken", "floor", undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, filesystemChoice)).toMatchObject({ ok: false, message: expect.stringContaining("profile failed to load") });
|
||||
}
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a filesystem seat's broken named profile during pre-cutover checks", async () => {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(),
|
||||
verifyFilesystemPolicy: async () => ({ ok: true, verified: false, reason: "profile configuration cannot be compared" }),
|
||||
verifyProfilePreflight: async profile => ({ ok: false, profile, error: "profile failed to load" }) });
|
||||
expect(await adapter.preflightLaunch({ ...makeBinding(), codexPermissions: filesystemChoice, codexConfigProfile: "broken" }))
|
||||
.toMatchObject({ ok: false, error: expect.stringContaining("profile failed to load") });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #875 Codex: on a fresh launch of a supported Codex, OpenRig's managed guidance goes in as the launch's developer
|
||||
// instructions (`-c developer_instructions`, Codex 0.164.0), so the person's tracked repository AGENTS.md and the Codex
|
||||
// home are left alone. Anything else keeps the repository AGENTS.md, as before.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {compactionRoutes} from '../src/routes/compaction.js';
|
||||
import {mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync, readFileSync, symlinkSync, lstatSync, statSync, utimesSync, chmodSync, existsSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join, dirname} from 'node:path';
|
||||
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT, MANUAL_SEAT_READ_INTERVAL_MS} from '../src/domain/claude-compaction-enforcer.js';
|
||||
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT} from '../src/domain/claude-compaction-enforcer.js';
|
||||
import {SessionTransport, CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS} from '../src/domain/session-transport.js';
|
||||
import {SeatDeliveryGuard} from '../src/domain/seat-delivery-guard.js';
|
||||
import {TmuxAdapter} from '../src/adapters/tmux.js';
|
||||
@@ -498,7 +498,7 @@ it('manual route preserves a non-timeout idle-wait refusal after map completion'
|
||||
it('preparation receipt: unresolved permission at deadline discloses the delivered prep and exact attempt',async()=>{
|
||||
const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');});
|
||||
const res=await routeFixture(f)();const body=await res.json();
|
||||
expect(res.status).toBe(409);expect(body.reason).toBe('preparation_needs_input');expect(body.error).toContain('Answer the prompt in the seat');
|
||||
expect(res.status).toBe(409);expect(body.reason).toBe('map_complete_seat_never_idle');
|
||||
expect(body.preparation).toMatchObject({attemptId:f.e.getPreparationState(seat)!.attemptId,delivery:'delivered'});
|
||||
expect(body.error).toContain('Preparation was sent');expect(body.error).not.toContain('could not be sent');
|
||||
expect(body.error).toContain('disarmed');expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1);
|
||||
@@ -854,74 +854,10 @@ it('manual attention clears within the same attempt with one preparation and one
|
||||
});
|
||||
it('manual unresolved attention consumes the deadline and never revives',async()=>{
|
||||
const f=fixture();const start=f.clock();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_needs_input'});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
|
||||
expect(f.clock()).toBe(start+1000);expect(compacts(f)).toHaveLength(0);
|
||||
f.activity('idle');stageMap(f);await f.e.maybeAutoCompact(input);expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
// The manual wait reads the seat while the map is written too, and the attempt keeps only the latest reading: a prompt
|
||||
// is reported while it's there and named at expiry only when the latest reading still shows it.
|
||||
const readsBy=(ms:number)=>Math.floor(ms/MANUAL_SEAT_READ_INTERVAL_MS)+1;
|
||||
it('manual: a prompt before the map is reported in --state and names the expiry',async()=>{
|
||||
const f=fixture(20_000);const seen:any[]=[];
|
||||
f.onSleep(async()=>{f.activity('needs_input');seen.push(f.e.getPreparationState(seat)?.awaitingInput);});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_needs_input'});
|
||||
expect(seen).toContainEqual(expect.objectContaining({reason:'target_needs_input',current:true}));
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it('manual: a prompt before the map, answered, then the completed map compacts on the same attempt',async()=>{
|
||||
const f=fixture(20_000);let n=0;let attempt='';const seen:any[]=[];
|
||||
f.onSleep(async()=>{n++;attempt||=f.e.getPreparationState(seat)!.attemptId;seen.push(f.e.getPreparationState(seat)?.awaitingInput);
|
||||
if(n<30)f.activity('needs_input');else{f.activity('idle');stageMap(f);}});
|
||||
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(true);
|
||||
expect(seen).toContainEqual(expect.objectContaining({current:true}));
|
||||
expect(f.e.getPreparationState(seat)).toMatchObject({attemptId:attempt,status:'compact-sent'});
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
});
|
||||
it.each(['running','unknown'] as const)('manual: a prompt answered into %s is not reported as current at expiry',async(after)=>{
|
||||
const f=fixture(20_000);let n=0;vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
f.onSleep(async()=>{n++;stageMap(f);f.activity(n<8?'needs_input':after);});
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
|
||||
const state=f.e.getPreparationState(seat)!;
|
||||
if(after==='running')expect(state.awaitingInput).toBeUndefined();
|
||||
else expect(state.awaitingInput).toMatchObject({reason:'target_needs_input',current:false});
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it('manual: prompt, then idle, then busy before the map is done: no stale prompt, the map expiry as before',async()=>{
|
||||
const f=fixture(20_000);let n=0;
|
||||
f.onSleep(async()=>{n++;f.activity(n<30?'needs_input':n<50?'idle':'running');});
|
||||
expect(readsBy(20_000)).toBeGreaterThanOrEqual(4);
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_incomplete'});
|
||||
expect(f.e.getPreparationState(seat)!.awaitingInput).toBeUndefined();
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
// The idle wait after the map can run to the deadline, so readings it completes must reach the attempt as they land: a
|
||||
// --state read (which also reconciles the deadline) during that wait describes the seat now.
|
||||
it.each(['running','unknown'] as const)('manual: a prompt answered into %s while the post-map wait runs; --state reads during the wait, and at the deadline, see no current prompt',async(after)=>{
|
||||
const f=fixture(20_000);vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
let n=0;let prompt:any;let attempt='';
|
||||
f.onSleep(async()=>{n++;if(n===1){attempt=f.e.getPreparationState(seat)!.attemptId;stageMap(f);f.activity('needs_input');}
|
||||
else if(n===2){prompt=f.e.getPreparationState(seat)?.awaitingInput;f.activity(after);}});
|
||||
// After the prompt the seat works on, or can't be read; on the third such reading of one wait, two --state reads land:
|
||||
// one as it is, one at the deadline, while that wait is still in flight.
|
||||
const read=(f.transport as any).classifySendReadiness;let later=0;let during:any;let atDeadline:any;
|
||||
(f.transport as any).classifySendReadiness=async(...args:any[])=>{
|
||||
if(n>=2&&++later===3){
|
||||
during=f.e.getPreparationState(seat)?.awaitingInput??null;
|
||||
f.advance(f.e.getPreparationState(seat)!.deadlineAt!-f.clock());
|
||||
atDeadline=f.e.getPreparationState(seat);
|
||||
}
|
||||
return read(...args);
|
||||
};
|
||||
const outcome=await f.e.triggerManualCompact(input,{operatorInitiated:true});
|
||||
expect(prompt).toMatchObject({reason:'target_needs_input',current:true});
|
||||
// A working seat clears the prompt; one that can't be read keeps it only as an earlier observation, never a current one.
|
||||
const earlier=after==='running'?undefined:expect.objectContaining({reason:'target_needs_input',current:false});
|
||||
expect(during??undefined).toEqual(earlier);
|
||||
expect(atDeadline).toMatchObject({attemptId:attempt,status:'stopped',reason:'map_complete_seat_never_idle'});
|
||||
expect(atDeadline.awaitingInput).toEqual(earlier);
|
||||
expect(outcome).toMatchObject({triggered:false,reason:'map_complete_seat_never_idle'});
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it('manual default accepts a map after120s but before180s',async()=>{
|
||||
expect(MANUAL_PREP_WAIT_MS_DEFAULT).toBe(180_000);
|
||||
const f=fixture(null);const start=f.clock();f.onSleep(async()=>{if(f.clock()>=start+130_000)stageMap(f);});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TerminalPolicyStore } from "../src/domain/terminal-policy.js";
|
||||
//
|
||||
// Proves: the route resolves the target + reads the EXISTING context-usage
|
||||
// projection BEFORE calling the enforcer (known usage permits a manual request
|
||||
// independently of the threshold); non-Claude → 422 runtime_filter; unknown usage still prepares and compacts
|
||||
// independently of the threshold); non-Claude → 422 runtime_filter; unknown usage → 409 no_usage_data
|
||||
// (route passes null, never invents a value); ambiguous → 409; missing field →
|
||||
// 400; enforcer-unwired → 503. Reuses the shipped SessionTransport + the SAME
|
||||
// ClaudeCompactionEnforcer (no second path).
|
||||
@@ -277,7 +277,7 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
|
||||
expect(sentTexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("unknown usage (no sample persisted): an explicit manual request doesn't depend on the threshold, so it prepares and compacts", async () => {
|
||||
it("unknown usage (no sample persisted) → 409 no_usage_data (never triggers blind)", async () => {
|
||||
seed();
|
||||
const { app, sentTexts } = buildApp();
|
||||
const res = await app.request("/api/compaction/trigger", {
|
||||
@@ -285,11 +285,9 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ ok: true, stage: "compact-sent" });
|
||||
expect(sentTexts[0]).toContain("OpenRig manual compaction was requested for this seat");
|
||||
expect(sentTexts[0]).not.toContain("null");
|
||||
expect(sentTexts.some((text) => text.startsWith("/compact"))).toBe(true);
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).reason).toBe("no_usage_data");
|
||||
expect(sentTexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ambiguous session across rigs → 409", async () => {
|
||||
|
||||
@@ -164,45 +164,4 @@ describe("restore-check current-state eligibility correction", () => {
|
||||
expect((await (await app.request(`/api/rigs/${unknown.id}/status`)).json()).status).toBe("unknown");
|
||||
expect(changes()).toEqual(before);
|
||||
});
|
||||
|
||||
// A seat that only ever resumed or was attached has no saved startup context. With a resume token, restore resumes it
|
||||
// with an empty plan; eligibility and restore-check follow the same rule.
|
||||
function seedMoved(name: string, token: boolean, pod = true) {
|
||||
const { rigRepo, sessionRegistry, db } = setup;
|
||||
const rig = rigRepo.createRig(name);
|
||||
if (pod) db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run(`pod-${name}`, rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, pod ? "dev.moved" : "moved", { runtime: "codex", cwd: home, ...(pod ? { podId: `pod-${name}` } : {}) });
|
||||
const session = sessionRegistry.registerSession(node.id, pod ? `dev-moved@${name}` : `moved@${name}`);
|
||||
sessionRegistry.updateStatus(session.id, "stopped");
|
||||
if (token) db.prepare("UPDATE sessions SET resume_type='codex_id',resume_token='fixture-codex-token' WHERE id=?").run(session.id);
|
||||
return rig;
|
||||
}
|
||||
const startupContextCheck = (result: { checks: Array<{ check: string }> }) =>
|
||||
result.checks.find((c) => c.check.endsWith(".startup-context")) as { status: string; evidence: string; remediation: string };
|
||||
|
||||
it("a pod-aware seat with a resume token and no saved startup context is eligible, and restore-check says it resumes", async () => {
|
||||
const rig = seedMoved("moved", true);
|
||||
expect(eligibility.assessCurrentStateRehydrateEligibility(setup.db, setup.rigRepo.getRig(rig.id)!)).toEqual({ ok: true, blockers: [] });
|
||||
const ctx = startupContextCheck(await check(rig.name));
|
||||
expect(ctx).toMatchObject({ status: "yellow", evidence: expect.stringContaining("Restore tries dev-moved@moved's saved conversation without replaying a saved startup plan, and names this gap.") });
|
||||
// A fresh seat launch refuses without a saved startup context, so none is offered.
|
||||
expect(ctx.remediation).toBe("No startup-context repair is needed to restore it.");
|
||||
});
|
||||
|
||||
it("a legacy seat with a resume token and no saved startup context reads yellow without promising a named gap", async () => {
|
||||
const rig = seedMoved("legacy-moved", true, false);
|
||||
const ctx = startupContextCheck(await check(rig.name));
|
||||
expect(ctx).toMatchObject({ status: "yellow", evidence: expect.stringContaining("without replaying a saved startup plan.") });
|
||||
expect(ctx.evidence).not.toContain("names this gap");
|
||||
expect(ctx.remediation).toContain("No startup-context repair is needed to restore it.");
|
||||
});
|
||||
|
||||
it("without a resume token the seat stays blocked, and restore-check names a supported command instead of 'recreate'", async () => {
|
||||
const rig = seedMoved("unmoved", false);
|
||||
expect(eligibility.assessCurrentStateRehydrateEligibility(setup.db, setup.rigRepo.getRig(rig.id)!).blockers)
|
||||
.toContain("node dev.moved is pod-aware but has no persisted startup context or resume token");
|
||||
const ctx = startupContextCheck(await check(rig.name));
|
||||
expect(ctx).toMatchObject({ status: "red", remediation: "No command records startup context for this seat yet. To see what restore would do for it, preview with: rig up unmoved --existing --plan (read-only; it records nothing)." });
|
||||
expect(ctx.remediation).not.toContain("--fresh");
|
||||
});
|
||||
});
|
||||
@@ -371,29 +371,24 @@ describe("contained Claude restore activity resources", () => {
|
||||
});
|
||||
|
||||
// Maintainer review on #2373: a pod-aware agent with no startup context
|
||||
// must get no hooks write from this recovery. With a resume token, restore
|
||||
// tries its resume on an empty plan and names the gap, still with no hooks.
|
||||
it("a pod-aware seat with no startup context resumes with an empty plan and the gap named, and gets no claude hooks", async () => {
|
||||
// must get no hooks write from this recovery — it still ends at the
|
||||
// "Harness not started" gate, and the restore itself is unchanged.
|
||||
it("does not project claude hooks for a pod-aware seat with no startup context", async () => {
|
||||
const f = seed(true, false, "claude-code", false);
|
||||
const project = vi.spyOn(adapter, "project");
|
||||
const desired = vi.fn((): { status: "known"; selected: boolean } => ({ status: "known", selected: true }));
|
||||
const result = await restore(f, adapter, () => {}, desired);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
// It reaches native resume, as a seat with a saved context does here (this fixture gives the joined proof no process).
|
||||
expect(adapter.launchHarness).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ resumeToken: token }));
|
||||
expect(result.result.nodes[0]).toMatchObject({
|
||||
status: "attention_required", error: expect.stringContaining("joined restore proof is incomplete"),
|
||||
restoreGaps: ["startup_context: no saved startup context; restore tried the saved conversation without replaying a saved startup plan."],
|
||||
status: "attention_required", error: "Harness not started: no startup context for p.seat.",
|
||||
});
|
||||
expect(result.result.warnings).toContain("Restore: p.seat: no saved startup context, so restore tries its saved conversation without replaying a saved startup plan.");
|
||||
expect(desired).not.toHaveBeenCalled();
|
||||
const claudeRecovery = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
|
||||
expect(claudeRecovery).toBeUndefined();
|
||||
expect(owned()).toEqual([]);
|
||||
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
|
||||
// Nothing is written for it: it still has no saved startup context.
|
||||
expect(db.prepare("SELECT * FROM node_startup_context WHERE node_id=?").get(f.node.id)).toBeUndefined();
|
||||
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
|
||||
});
|
||||
|
||||
// CodeRabbit follow-up on #2373: the snapshot node may have no cwd while
|
||||
|
||||
@@ -2825,113 +2825,6 @@ describe("RestoreOrchestrator", () => {
|
||||
for (const opts of seen) expect(Object.keys(opts).sort()).toEqual(["name", "resumeToken", "resumeType"]);
|
||||
});
|
||||
|
||||
// A seat that only ever resumed or was attached has a resume token but no saved startup context. A resume replays none
|
||||
// anyway, so restore resumes it with an empty plan and names the gap, rather than stopping it.
|
||||
function seatWithoutStartupContext(runtime: "claude-code" | "codex", token: string | null, savedEmptyContext = false) {
|
||||
const rigName = savedEmptyContext ? "saved-rig" : "test-rig";
|
||||
const rig = rigRepo.createRig(rigName);
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run(`pod-${rigName}`, rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, "dev.moved", { runtime, podId: `pod-${rigName}` });
|
||||
const session = sessionRegistry.registerSession(node.id, `dev-moved@${rigName}`);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
if (token) sessionRegistry.updateResumeToken(session.id, runtime === "codex" ? "codex_id" : "claude_id", token);
|
||||
if (savedEmptyContext) {
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(node.id, "[]", "[]", "[]", runtime);
|
||||
}
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "test");
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
|
||||
const adapter = {
|
||||
runtime,
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async (_binding: unknown, opts: { resumeToken?: string }) => ({
|
||||
ok: true as const, resumeToken: opts.resumeToken ?? "fresh-token", resumeType: runtime === "codex" ? "codex_id" : "claude_id" })),
|
||||
};
|
||||
return { node, snap, adapter };
|
||||
}
|
||||
|
||||
it.each(["claude-code", "codex"] as const)("a pod-aware %s seat with a resume token and no saved startup context resumes with an empty plan and a named gap", async (runtime) => {
|
||||
const token = runtime === "codex" ? "019a0000-0000-7000-8000-00000000c0de" : "00000000-0000-4000-8000-0000000c1a0d";
|
||||
const { node, snap, adapter } = seatWithoutStartupContext(runtime, token);
|
||||
const orch = createOrchestrator({ listProcesses: nativeLineage(runtime, token) });
|
||||
const result = await orch.restore(snap.id, { adapters: { [runtime]: adapter } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(adapter.launchHarness).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ resumeToken: token }));
|
||||
const node0 = result.result.nodes[0]!;
|
||||
expect(node0.error ?? "").not.toContain("no startup context");
|
||||
// It ends where the same seat with a saved (empty) startup context ends: the resume is the same, only the gap differs.
|
||||
const saved = seatWithoutStartupContext(runtime, token, true);
|
||||
const withContext = await createOrchestrator({ listProcesses: nativeLineage(runtime, token) })
|
||||
.restore(saved.snap.id, { adapters: { [runtime]: saved.adapter } });
|
||||
expect(withContext.ok).toBe(true);
|
||||
if (!withContext.ok) return;
|
||||
const savedNode = withContext.result.nodes[0]!;
|
||||
expect({ status: node0.status, error: node0.error?.replaceAll("test-rig", "saved-rig") }).toEqual({ status: savedNode.status, error: savedNode.error });
|
||||
expect(savedNode.restoreGaps).toBeUndefined();
|
||||
if (runtime === "claude-code") expect(node0.status).toBe("resumed");
|
||||
expect(node0.restoreGaps).toEqual(["startup_context: no saved startup context; restore tried the saved conversation without replaying a saved startup plan."]);
|
||||
expect(result.result.warnings).toContain("Restore: dev.moved: no saved startup context, so restore tries its saved conversation without replaying a saved startup plan.");
|
||||
// Nothing is replayed or projected, and no startup context is written for it.
|
||||
for (const [plan] of adapter.project.mock.calls as unknown as Array<[{ entries: unknown[] }]>) expect(plan.entries).toEqual([]);
|
||||
for (const [files] of adapter.deliverStartup.mock.calls as unknown as Array<[unknown[]]>) expect(files).toEqual([]);
|
||||
expect(db.prepare("SELECT 1 FROM node_startup_context WHERE node_id = ?").get(node.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
// The gap is named before the resume's outcome is known, so it never claims one: a resume that finds no conversation,
|
||||
// or fails, keeps the same attempt-neutral words and its own status.
|
||||
it.each([
|
||||
["finds no conversation", { ok: false as const, error: "Claude resume failed: no conversation found for the requested session", recovery: "retry_fresh", missingConversation: true as const }, "awaiting-decision"],
|
||||
["fails to start", { ok: false as const, error: "Claude resume failed: the harness exited" }, "failed"],
|
||||
] as const)("with no saved startup context, a resume that %s keeps its status and claims no resume", async (_label, launch, status) => {
|
||||
const token = "00000000-0000-4000-8000-0000000ba5e1";
|
||||
const { snap, adapter } = seatWithoutStartupContext("claude-code", token);
|
||||
adapter.launchHarness.mockResolvedValueOnce(launch as never);
|
||||
const result = await createOrchestrator({ listProcesses: nativeLineage("claude-code", token) }).restore(snap.id, { adapters: { "claude-code": adapter } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const node0 = result.result.nodes[0]!;
|
||||
expect(adapter.launchHarness).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ resumeToken: token }));
|
||||
expect(node0.status).toBe(status);
|
||||
// A fresh seat launch refuses without a saved startup context, so the rollback offers none.
|
||||
expect(node0.error ?? "").not.toContain("--fresh");
|
||||
if (status === "awaiting-decision") expect(node0.error).toContain("This seat has no saved startup context, and no command records one for it yet, so no fresh start is offered here. Restore the original session manually.");
|
||||
expect(node0.restoreGaps).toEqual(["startup_context: no saved startup context; restore tried the saved conversation without replaying a saved startup plan."]);
|
||||
expect(result.result.warnings).toContain("Restore: dev.moved: no saved startup context, so restore tries its saved conversation without replaying a saved startup plan.");
|
||||
expect([...(node0.restoreGaps ?? []), ...result.result.warnings].filter((line) => line.includes("startup context"))
|
||||
.some((line) => /\bresumed\b/.test(line))).toBe(false);
|
||||
});
|
||||
|
||||
it("with no saved startup context, a seat with no resume token still waits for a decision, and starts no harness", async () => {
|
||||
const { snap, adapter } = seatWithoutStartupContext("claude-code", null);
|
||||
const orch = createOrchestrator();
|
||||
const result = await orch.restore(snap.id, { adapters: { "claude-code": adapter } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.result.nodes[0]).toMatchObject({ status: "awaiting-decision", error: expect.stringContaining("no resume token was captured") });
|
||||
expect(result.result.nodes[0]!.restoreGaps).toBeUndefined();
|
||||
expect(adapter.launchHarness).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with no saved startup context, a deliberate fresh start isn't turned into a resume: it stops at the gate as before", async () => {
|
||||
const { snap, adapter } = seatWithoutStartupContext("claude-code", "00000000-0000-4000-8000-0000000f7e54");
|
||||
const orch = createOrchestrator();
|
||||
const result = await orch.restore(snap.id, { adapters: { "claude-code": adapter }, freshLogicalIds: ["dev.moved"] });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.result.nodes[0]).toMatchObject({ status: "attention_required", error: "Harness not started: no startup context for dev.moved." });
|
||||
expect(result.result.nodes[0]!.restoreGaps).toBeUndefined();
|
||||
expect(adapter.launchHarness).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pod-aware chooser attention preserves the attempted native token for no-input reconciliation", async () => {
|
||||
const rig = rigRepo.createRig("test-rig");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-chooser", rig.id, "Dev");
|
||||
@@ -6064,24 +5957,6 @@ describe("RestoreOrchestrator", () => {
|
||||
});
|
||||
|
||||
describe("launch_wrapper native resume, fail-closed, and snapshot retention", () => {
|
||||
it.each(["replace", "clear"])("legacy Codex restore reads the live filesystem selection after snapshot (%s)", async change => {
|
||||
const rig = rigRepo.createRig("fs-restore");
|
||||
const oldPolicy = { approval: "never", filesystem: { ":minimal": "read", "/old": "write" }, network_access: false } satisfies import("../src/domain/codex-member-permissions.js").CodexFilesystemPermissions;
|
||||
const currentPolicy = { approval: "never", filesystem: { ":minimal": "read", "/current": "write", "/current/private": "deny" }, network_access: false };
|
||||
const node = rigRepo.addNode(rig.id, "codex-worker", { runtime: "codex", cwd: "/repo/codex", codexPermissions: oldPolicy });
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-worker@fs-restore");
|
||||
db.prepare("UPDATE sessions SET resume_type = 'codex_id', resume_token = 'fs-thread', restore_policy = 'resume_if_possible' WHERE id = ?").run(session.id);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "manual");
|
||||
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(change === "replace" ? JSON.stringify(currentPolicy) : null, node.id);
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
const codexResume = mockCodexResume({ ok: true as const });
|
||||
expect(await createOrchestrator({ codex: codexResume, listProcesses: nativeLineage("codex", "fs-thread") }).restore(snap.id))
|
||||
.toMatchObject({ ok: true });
|
||||
const call = vi.mocked(codexResume.resume).mock.calls[0]!;
|
||||
expect(call[12]).toEqual(change === "replace" ? currentPolicy : undefined);
|
||||
});
|
||||
|
||||
it("passes current launch_wrapper as the new LAST arg after effort on Pi native resume and after beforeNativeDispatch (with undefined padding) on Codex native resume", async () => {
|
||||
const rig = rigRepo.createRig("r99");
|
||||
const piNode = rigRepo.addNode(rig.id, "pi-worker", {
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("POST and DELETE /api/rigs/:rigId/edges", () => {
|
||||
]) {
|
||||
const refused = await add(body);
|
||||
expect(refused.status).toBe(400);
|
||||
expect(await refused.json()).toMatchObject({ ok: false, code: "edge_cycle", message: expect.stringContaining("launch and restore require an acyclic order") });
|
||||
expect(await refused.json()).toMatchObject({ ok: false, code: "edge_cycle" });
|
||||
}
|
||||
expect(state()).toEqual(before);
|
||||
|
||||
|
||||
@@ -1293,83 +1293,6 @@ describe("SeatHandoverService", () => {
|
||||
detail: expect.stringContaining("without additional writable directory grants, as on a read-only fresh launch") } } } });
|
||||
});
|
||||
|
||||
it("rebuild keeps the saved filesystem policy instead of carrying a broader observed predecessor", async () => {
|
||||
const { node } = seedSeat();
|
||||
const own = { approval: "never", filesystem: { ":minimal": "read", "/work": "write", "/work/private": "deny" }, network_access: false };
|
||||
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(JSON.stringify(own), node.id);
|
||||
currentCodexPermissions.mockResolvedValue({ state: "observed", envelope: { approvalPolicy: "never", sandboxPolicy: { type: "danger-full-access" } },
|
||||
profile: null, processStartedAt: "Mon Apr 20 12:00:00 2026", observedAt: "2026-04-20T12:01:00Z" });
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", source: "rebuild", reason: "context wall" });
|
||||
expect(result).toMatchObject({ ok: true, result: { sourceOutcome: { permissions: { state: "selection_used",
|
||||
detail: expect.stringContaining("explicit filesystem policy and no additional writable directory grants") } } } });
|
||||
const binding = launchHarness.mock.calls[0]![0];
|
||||
expect(binding.codexRebuildPermissions).toBeUndefined();
|
||||
expect(binding.codexPermissions).toEqual(own);
|
||||
});
|
||||
|
||||
it.each(["fresh", "fork:dev-impl@seat-rig", "rebuild"])(
|
||||
"keeps the predecessor running when filesystem preflight refuses before %s handover", async source => {
|
||||
const { node, sessionId } = seedSeat();
|
||||
if (!sessionId) throw new Error("fixture requires a current session");
|
||||
sessionRegistry.updateResumeToken(sessionId, "native-thread", "codex_id");
|
||||
const own = { approval: "never", filesystem: { ":minimal": "read", "/project": "write" } };
|
||||
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(JSON.stringify(own), node.id);
|
||||
const before = sessionRegistry.getBindingForNode(node.id);
|
||||
const remedy = "Fix: Remove the conflicting lower-layer access before another handover.";
|
||||
const verifyFilesystemPolicy = vi.fn(async () => ({ ok: false as const, error: `native policy refuses successor\n${remedy}` }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux: tmux(), codexHome: "/seat-home", verifyFilesystemPolicy,
|
||||
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, listFiles: () => [] } });
|
||||
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
|
||||
tmuxAdapter: tmux(), runtimeAdapters: { codex: adapter }, sleep: async () => {}, readinessTimeoutMs: 50 });
|
||||
expect(await handover.handover({ seatRef: "dev-impl@seat-rig", source, reason: "check successor policy" }))
|
||||
.toMatchObject({ ok: false, code: "successor_create_failed", message: expect.stringContaining("native policy"),
|
||||
guidance: expect.stringContaining(remedy) });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledWith(expect.objectContaining({ codexHome: "/seat-home", cwd: "/project" }));
|
||||
expect(signalPaneProcess).not.toHaveBeenCalled();
|
||||
expect(respawnPane).not.toHaveBeenCalled();
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
expect(sessionRegistry.getBindingForNode(node.id)).toEqual(before);
|
||||
});
|
||||
|
||||
it("checks the saved filesystem policy before signaling the predecessor and then hands over", async () => {
|
||||
const { node } = seedSeat();
|
||||
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?")
|
||||
.run(JSON.stringify({ approval: "never", filesystem: { ":minimal": "read" } }), node.id);
|
||||
const verifyFilesystemPolicy = vi.fn(async () => ({ ok: true as const, verified: true as const }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux: tmux(), codexHome: "/seat-home", verifyFilesystemPolicy,
|
||||
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {} } });
|
||||
vi.spyOn(adapter, "launchHarness").mockImplementation(launchHarness);
|
||||
vi.spyOn(adapter, "checkReady").mockImplementation(checkReady);
|
||||
signalPaneProcess.mockImplementation(async () => {
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(1);
|
||||
return { ok: true };
|
||||
});
|
||||
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
|
||||
tmuxAdapter: tmux(), runtimeAdapters: { codex: adapter }, sleep: async () => {}, readinessTimeoutMs: 50 });
|
||||
expect(await handover.handover({ seatRef: "dev-impl@seat-rig", source: "fresh", reason: "check successor policy" }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(signalPaneProcess).toHaveBeenCalledTimes(1);
|
||||
expect(launchHarness).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues handover with the authored policy when native verification is inconclusive", async () => {
|
||||
const { node } = seedSeat();
|
||||
db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?")
|
||||
.run(JSON.stringify({ approval: "never", filesystem: { ":minimal": "read" } }), node.id);
|
||||
const verifyFilesystemPolicy = vi.fn(async () => ({ ok: true as const, verified: false as const, reason: "native read timed out" }));
|
||||
const adapter = new CodexRuntimeAdapter({ tmux: tmux(), codexHome: "/seat-home", verifyFilesystemPolicy,
|
||||
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {} } });
|
||||
vi.spyOn(adapter, "launchHarness").mockImplementation(launchHarness);
|
||||
vi.spyOn(adapter, "checkReady").mockImplementation(checkReady);
|
||||
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
|
||||
tmuxAdapter: tmux(), runtimeAdapters: { codex: adapter }, sleep: async () => {}, readinessTimeoutMs: 50 });
|
||||
expect(await handover.handover({ seatRef: "dev-impl@seat-rig", source: "rebuild", reason: "keep authored policy" }))
|
||||
.toMatchObject({ ok: true, result: { warnings: expect.arrayContaining(["policy not verified: native read timed out"]) } });
|
||||
expect(verifyFilesystemPolicy).toHaveBeenCalledTimes(1);
|
||||
expect(launchHarness.mock.calls[0]?.[0]).toMatchObject({ codexPermissions: { filesystem: { ":minimal": "read" } } });
|
||||
expect(signalPaneProcess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["observed", "unverified"] as const)("rebuild keeps saved-envelope launch additions when predecessor permissions are %s", async state => {
|
||||
const { node } = seedSeat({ codexConfigProfile: "locked" });
|
||||
const own = { sandbox: "workspace-write", approval: "on-request", writable_roots: ["/shared/state"],
|
||||
|
||||
@@ -64,44 +64,6 @@ describe("SuccessorSessionLauncher", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it.each([false, true])("leaves the predecessor untouched when launch preflight refuses or throws (throws: %s)", async throws => {
|
||||
const preflightLaunch = vi.fn(async () => {
|
||||
if (throws) throw new Error("preflight unavailable");
|
||||
return { ok: false as const, error: "policy conflicts with the successor" };
|
||||
});
|
||||
const runtime = { ...fakeAdapter("codex"), preflightLaunch };
|
||||
const tmux = { listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead,
|
||||
getDefaultShell, getPaneCommand } as unknown as TmuxAdapter;
|
||||
const guarded = new SuccessorSessionLauncher(tmux, discoveryRepo, { runtimeAdapters: { codex: runtime } });
|
||||
const onReplacementStarted = vi.fn();
|
||||
const result = await guarded.createSuccessor({ node: { id: "node-1", runtime: "codex", cwd: "/w" },
|
||||
departingSessionName: "dev-impl@rig", onReplacementStarted });
|
||||
expect(result).toMatchObject({ ok: false, code: "successor_preflight_failed", replacementStarted: false });
|
||||
expect(signalPaneProcess).not.toHaveBeenCalled();
|
||||
expect(setRemainOnExit).not.toHaveBeenCalled();
|
||||
expect(respawnPane).not.toHaveBeenCalled();
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
expect(launchHarness).not.toHaveBeenCalled();
|
||||
expect(onReplacementStarted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights the same binding it launches and retains an inconclusive-policy warning", async () => {
|
||||
const preflightLaunch = vi.fn(async () => ({ ok: true as const, warnings: ["policy not verified: native read timed out"] }));
|
||||
const runtime = { ...fakeAdapter("codex"), preflightLaunch };
|
||||
const tmux = { listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead,
|
||||
getDefaultShell, getPaneCommand } as unknown as TmuxAdapter;
|
||||
const guarded = new SuccessorSessionLauncher(tmux, discoveryRepo, { runtimeAdapters: { codex: runtime } });
|
||||
signalPaneProcess.mockImplementation(async () => {
|
||||
expect(preflightLaunch).toHaveBeenCalledTimes(1);
|
||||
return { ok: true };
|
||||
});
|
||||
const result = await guarded.createSuccessor({ node: { id: "node-1", runtime: "codex", cwd: "/w",
|
||||
codexPermissions: { approval: "never", filesystem: { ":minimal": "read" } } },
|
||||
departingSessionName: "dev-impl@rig", occupantGeneration: "successor-generation" });
|
||||
expect(result).toMatchObject({ ok: true, warnings: ["policy not verified: native read timed out"] });
|
||||
expect(preflightLaunch.mock.calls[0]?.[0]).toBe(launchHarness.mock.calls[0]?.[0]);
|
||||
});
|
||||
|
||||
it("revokes the staged attempt if its separate readiness probe fails", async () => {
|
||||
db.prepare("INSERT INTO rigs (id,name) VALUES ('rig-bound','bound')").run();
|
||||
db.prepare("INSERT INTO nodes (id,rig_id,logical_id,runtime) VALUES ('node-1','rig-bound','dev.impl','codex')").run();
|
||||
|
||||
@@ -31,7 +31,6 @@ import { workflowInstanceVersionSchema } from "../src/db/migrations/049_workflow
|
||||
import { workflowSpecJsonSchema } from "../src/db/migrations/050_workflow_spec_json.js";
|
||||
import { workflowResumeSchema } from "../src/db/migrations/051_workflow_resume.js";
|
||||
import { workflowInstanceBoundRigSchema } from "../src/db/migrations/052_workflow_instance_bound_rig.js";
|
||||
import { workflowInstanceScopeSchema } from "../src/db/migrations/110_workflow_instance_scope.js";
|
||||
|
||||
const SPEC = `workflow:
|
||||
id: routes-fixture
|
||||
@@ -86,7 +85,6 @@ describe("workflow routes (PL-004 Phase D)", () => {
|
||||
coreSchema, eventsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
workflowInstanceBoundRigSchema, workflowInstanceScopeSchema,
|
||||
]);
|
||||
bus = new EventBus(db);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
@@ -310,27 +308,6 @@ describe("workflow routes (PL-004 Phase D)", () => {
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters instance scope before the limit and combines exact selectors", async () => {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const response = await app.request("/api/workflow/instantiate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ specPath, rootObjective: `item ${i}`, createdBySession: "ops@rig" }) });
|
||||
expect(response.status).toBe(201);
|
||||
ids.push(((await response.json()) as { instance: { instanceId: string } }).instance.instanceId);
|
||||
}
|
||||
const update = db.prepare("UPDATE workflow_instances SET scope_json = ?, bound_rig = ?, created_at = ? WHERE instance_id = ?");
|
||||
update.run(JSON.stringify({ project: "p", mission: "other", slice: "s", tags: ["review"] }), "rig", "2026-01-01", ids[0]);
|
||||
update.run(JSON.stringify({ project: "p", mission: "m", slice: "s", tags: ["review", "ready"] }), "rig", "2026-01-02", ids[1]);
|
||||
update.run(JSON.stringify({ project: "p", mission: "m", slice: "s", tags: ["review"] }), "rig", "2026-01-03", ids[2]);
|
||||
const result = await app.request("/api/workflow/list?status=active&project=p&mission=m&slice=s&rig=rig&name=routes-fixture&tag=review&tag=ready&limit=1");
|
||||
expect(result.status).toBe(200);
|
||||
expect(((await result.json()) as Array<{ instanceId: string }>).map((row) => row.instanceId)).toEqual([ids[1]]);
|
||||
const allTags = await app.request("/api/workflow/list?status=active&project=p&mission=m&slice=s&rig=rig&name=routes-fixture&tag=review&tag=ready");
|
||||
expect(allTags.status).toBe(200);
|
||||
expect(((await allTags.json()) as Array<{ instanceId: string }>).map((row) => row.instanceId)).toEqual([ids[1]]);
|
||||
expect((await app.request("/api/workflow/list?limit=0")).status).toBe(400);
|
||||
expect((await app.request("/api/workflow/list?tag=")).status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /:instance_id returns 404 for unknown id", async () => {
|
||||
const res = await app.request("/api/workflow/unknown-id");
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"agent-operated-software/SKILL.md": "ec910bf5f631d7f20872786a565a0437a446fa2d5fd8b52b685979978243a6a8",
|
||||
"agent-operated-workflows/SKILL.md": "24e54cbfa979999f523516112670618b2926ae5d42c7aa0846c94db214857ea5",
|
||||
"applying-a-permission-policy/SKILL.md": "f7bba8d50b10fc8715f40a57bf82677ca9388c6718c8a162833d9c6daf2afdca",
|
||||
"claude-compaction-restore/SKILL.md": "e1a6ee501ae97d6de0a8df8dae01c088953f56907e76f10089908d7a8c5691a7",
|
||||
"claude-compaction-restore/SKILL.md": "cd8369a62647ad389a9b0cc91c6f784a0d93e2d2b874bf9570d47c2134647d1c",
|
||||
"claude-compaction-restore/reference/SKILL-v1.md": "6af2a889c9c38227fb186897c6e29185f81b215822070b469a6ceeb47734e133",
|
||||
"claude-compaction-restore/scripts/precompact-hook.mjs": "712e97c8beb02eb6c2815884a960778a225d8f6238dfe04a9b0547c34eaa131d",
|
||||
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "5584cff4ab36cd843e14c81bd201e4a2f8e498e995abacc4f81ec6c1fc556b10",
|
||||
@@ -93,7 +93,7 @@
|
||||
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
|
||||
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
|
||||
"openrig-skills/SKILL.md": "6d7bf933ac40531f11757a31a93d26dcbda617f1b85f48a21865b3a5c5e2ddaa",
|
||||
"openrig-user/SKILL.md": "6176761014c9ecd61675cb47d4de991de6dd126a92be7e06c3ef31aa0573f82e",
|
||||
"openrig-user/SKILL.md": "ef22c218348bdf06d0f56d9de86a3ac8a5f954eae161a212d1e1010a19e464b9",
|
||||
"orienting-to-an-inherited-seat/CHANGELOG.md": "a91f117e88034d8bae9b723916235e858fbc151983cc7c32784f5d4ba186274a",
|
||||
"orienting-to-an-inherited-seat/SKILL.md": "278c554b3bb4c481f2928499f02442d947ffd4751d60f830610b652e24cb0468",
|
||||
"queue-handoff/SKILL.md": "96251f6830006bb9d8ede2e5826ab936df4a9fb91090187a23ccaf860ab76336",
|
||||
|
||||
+25
-78
@@ -127,8 +127,8 @@ person needs in their project or home folder and name that path.
|
||||
joining as optional, with the effects below. Ask the person yes or no after this short explanation:
|
||||
- This adds me as a named member of the team, with my name on messages.
|
||||
- In a plain shell, joining writes no files in your project or personal settings.
|
||||
- Inside tmux, my sender and reply address is `<member>@<rig>`, even when your tmux session is named `0`;
|
||||
`rig attach --self` returns it and `rig whoami` confirms it; `--display-name` does not change it.
|
||||
- Inside tmux on 0.6.11, my sender and reply address is your existing tmux session name, even a number like `0`.
|
||||
`rig attach --self` returns that address and `rig whoami` confirms it; `--display-name` does not change it.
|
||||
- Inside tmux, other agents can type into this window, and stopping the team (`rig down`) also closes this tmux
|
||||
session (both from source, not hand-tested). Transcript recording and, for Claude Code, writing
|
||||
`.openrig/context-collector.cjs` and `.claude/settings.local.json` for the status line were hand-tested on 0.6.11.
|
||||
@@ -147,8 +147,7 @@ person needs in their project or home folder and name that path.
|
||||
`rig queue create --source <their id>@external --destination <seat> --summary "<one line>" --body-file <file>`: the
|
||||
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
|
||||
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
|
||||
shell above is the route. For work whose questions and result must come back to this session, use a return
|
||||
address ("If the person stays with you" below), not a declared name.
|
||||
shell above is the route.
|
||||
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
|
||||
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
|
||||
name someone else), and don't offer again.
|
||||
@@ -169,10 +168,9 @@ person needs in their project or home folder and name that path.
|
||||
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
|
||||
(never the logical ID or a guessed name) and send:
|
||||
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
|
||||
If the person stays here, hand the goal over as a task from your own address instead, so the operator's questions
|
||||
and the result come back to you ("If the person stays with you" below). Carry their constraints, which logins they
|
||||
have (an installed provider that isn't signed in doesn't count), and any permission choice they made, chosen or
|
||||
declined, in either handoff.
|
||||
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
|
||||
results to relay." Carry their constraints, which logins they have (an installed provider that isn't signed in
|
||||
doesn't count), and any permission choice they made, chosen or declined, in either handoff.
|
||||
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
|
||||
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
|
||||
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
|
||||
@@ -183,54 +181,11 @@ person needs in their project or home folder and name that path.
|
||||
Check those logins (`claude auth status`, `codex login status`), or go by what the person told you. If one is missing
|
||||
or you can't tell, say so before launching and let them choose: the operator adapts the team to the logins they have,
|
||||
or they sign in first.
|
||||
- **If the person stays with you** (unless they declined the Codex offer below and explicitly chose the operator
|
||||
route), you're their front door to the team: you hand it the goal, bring its questions to them and bring back what
|
||||
it produced, through the task queue rather than the operator's screen.
|
||||
- **Your return address:** if this session joined the team (above), it's the `OPENRIG_SESSION_NAME` that
|
||||
`rig attach` printed. Otherwise agree one with the person on the kernel's rig, such as `claude-app@kernel` or
|
||||
`codex-app@kernel`. It's an address the queue takes tasks for; nothing is launched for it and no file is written.
|
||||
If the person keeps more than one of these conversations, give each its own address (for example
|
||||
`claude-app-2@kernel`); one address shares its questions.
|
||||
To tell the team what it's for, the person can add it to a roster as a hosted member
|
||||
(`docs/reference/hosted-members.md` in the OpenRig package).
|
||||
Put it in front of every `rig queue` command: `OPENRIG_SESSION_NAME=<address> rig queue ...` (add
|
||||
`OPENRIG_HOME=<home>` when OpenRig doesn't use the default home). A `--source` name only labels the one task it's
|
||||
on: claiming and answering the team's questions need the address in `OPENRIG_SESSION_NAME`.
|
||||
- **Hand over the goal:** write it to a file with what the message above carries (that you're the agent that
|
||||
installed OpenRig and the person stays with you, the goal, folder, branch, constraints, logins and permission
|
||||
choices), and end it with this paragraph, filling in your address:
|
||||
|
||||
> To ask me anything, create a queue item to `<address>` (`rig queue create --destination <address> ...`), then
|
||||
> block this item on that new item's id (`rig queue block <this id> --on <question id>`), so my answer wakes you. I
|
||||
> check it when the person asks. Don't ask in your own terminal: the person isn't there. If you hand this item on,
|
||||
> copy this paragraph into the new item. When you finish, put the outcome in this item's resolution and any file in
|
||||
> its evidence ref.
|
||||
|
||||
Then run
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <canonicalSessionName> --summary "<the goal in a sentence>" --body-file <file> --verify`.
|
||||
Tell the person the task ID it prints, and keep the goal, your address and that ID: they're how you find this work
|
||||
again.
|
||||
- **The team's questions** come to your address as tasks:
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue list --owned --state pending,in-progress`. Read each in full
|
||||
(`rig queue show <id> --full`) and show the person the whole question in the team's words. Record only what they
|
||||
tell you, a condition included ("yes, but only on a branch"): claim it, then
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue update <id> --state done --closure-reason no-follow-on --resolution "<their words>"`.
|
||||
Add no label, quotation marks or notes of your own, and don't turn their words into a yes or a no: the agent that
|
||||
asked decides what they mean. You speak as yourself, never as the person. If they aren't there to answer, leave
|
||||
it open and tell them it's waiting; never answer for them.
|
||||
- **A question only on the operator's screen:** if the operator asks in its pane and nothing reached your address,
|
||||
read it with `rig capture <canonicalSessionName>`, show the person, and send their words as a new task to the
|
||||
operator that names your task's ID, not as keys typed into its pane.
|
||||
- **Delivery isn't the task's state.** A task's state says where the work is: pending (created and readable, not
|
||||
yet claimed), claimed or in progress (an agent took it), handed off (follow the next item) or done (read its
|
||||
resolution). Whether its agent was woken is a separate fact, the wake outcome `--verify` reports: `verified` (it
|
||||
rendered in the seat's pane) or `claimed` (picked up); `delivered-ack-pending` (sent, render unconfirmed: not
|
||||
proof it was woken); `retained` (the seat's typing guard held the wake, the task is still there to read, and it
|
||||
isn't retried automatically: wake the task's destination once its input is free with
|
||||
`rig send <destination> "task <id> is yours"`); `failed` (the wake didn't land, and the task is still
|
||||
owed: wake it the same way); `indeterminate` or `still-pending` (unknown, neither failed nor delivered: check
|
||||
before you wake it again). A pending task with no wake isn't lost or done.
|
||||
- **Nothing wakes this chat** when the team asks or finishes: check your address when the person asks or comes back.
|
||||
- **Relay the operator's questions** unless the person declined the Codex offer below and explicitly chose the operator route: the operator asks in its own pane, and the person may not be looking there.
|
||||
After each step you hand it, read its screen with `rig capture <canonicalSessionName>`. When it asks the person
|
||||
something (start this team? which folder? which option?), ask them here in the operator's words and send their
|
||||
answer back with `rig send`, or tell them to answer in the operator's pane. Unlike a prompt or a menu, a question
|
||||
doesn't show up as a stopped seat, so don't leave it to a background wait.
|
||||
- **If the operator answers with a model error,** such as "… not supported when using Codex with a ChatGPT account", its
|
||||
Codex sign-in works but that account can't run the model. OpenRig put the operator on Codex because the sign-in
|
||||
passed. Tell the person which model failed. If Claude Code is signed in, offer to move the operator to it; on their
|
||||
@@ -261,9 +216,9 @@ person needs in their project or home folder and name that path.
|
||||
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
|
||||
Another kind of menu you can answer with
|
||||
the key that screen shows, or ask the person. Capture it again before sending anything more.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Their questions and work for you arrive
|
||||
at your return address ("If the person stays with you" above). `rig capture <seat>@<rig>` shows what an agent is
|
||||
doing; it isn't how answers reach you.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
|
||||
`rig queue list --destination <your address>` and `rig queue show <id> --full`, and read an agent's screen with
|
||||
`rig capture <seat>@<rig>`.
|
||||
|
||||
### Codex: one offer, once, after OpenRig starts
|
||||
|
||||
@@ -302,29 +257,21 @@ offer, so the person sees the real file, not a placeholder:
|
||||
|
||||
Follow these steps by default. Only when the person declined the Codex offer above and explicitly chose the operator route does the operator own this follow-through.
|
||||
|
||||
- **Find the team's task:** if you gave the operator a task, start from it: when the operator hands it to the team's
|
||||
lead, `rig queue show <id> --json` lists the next item under `successors`. Otherwise, or if the operator started a
|
||||
new task for the lead (`dev-build@starter`, or `orch-lead` in `workshop` and `factory`; for starter and factory,
|
||||
`rig specs preview <name> --kind rig` says what each seat does; for a running team, `rig ps --nodes --rig <rig>`),
|
||||
find it with `rig queue list --destination <seat>@<rig> -a`.
|
||||
- **Find the team's task:** the operator gives the goal to the team's lead as a queue task: `dev-build@starter`, or
|
||||
`orch-lead` in `workshop` and `factory` (for starter and factory, `rig specs preview <name> --kind rig` says what
|
||||
each seat does; for a running team, `rig ps --nodes --rig <rig>`). Find it with
|
||||
`rig queue list --destination <seat>@<rig> -a`.
|
||||
- **Follow it to the result:** a task that changed hands isn't finished. `rig queue show <id> --full` names the seat it
|
||||
went to, and that seat's next task is the one whose `handedOffFrom` is that ID
|
||||
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result,
|
||||
and report what the record says; `rig capture <seat>@<rig>` only shows what an agent is doing right now. Then read
|
||||
what it produced and tell the person what the team actually did (the branch, commit, review or PR text), not just
|
||||
that you sent it.
|
||||
(`rig queue list --destination <seat>@<rig> -a -o json`). Follow each handoff until an agent reports the result, and
|
||||
use `rig capture <seat>@<rig>` to see what it's doing. Then read what it produced and tell the person what the team
|
||||
actually did (the branch, commit, review or PR text), not just that you sent it.
|
||||
- **Give the team more work:** write the goal to a file: what to change, in which repository and branch, what done
|
||||
looks like, and that the result comes back to you (the branch or commit, the review, any PR text), ending with the
|
||||
reply paragraph above. Then
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue create --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file> --verify`.
|
||||
A message informs, and a queue task is the work someone owns. Read its wake outcome as above before you send
|
||||
anything else.
|
||||
looks like, and that the result comes back to you (the branch or commit, the review, any PR text). Then
|
||||
`rig queue create --source <your name> --destination <seat>@<rig> --summary "<the goal in a sentence>" --body-file <file>`, and tell that agent with
|
||||
`rig send <seat>@<rig> "task <id> is yours"`. A message informs, and a queue task is the work someone owns.
|
||||
`--source` names you when this session hasn't joined the team.
|
||||
- **Then ask what's next.** If no team can take the work, fix that or ask the person. Don't quietly do it yourself.
|
||||
- **Coming back later:** in a new session, use the same return address and the task IDs you gave the person. Read
|
||||
each with `rig queue show <id> --json` and follow its `successors` to the newest item, as above;
|
||||
`OPENRIG_SESSION_NAME=<address> rig queue list --mine -a` lists everything this address sent or owns, finished work
|
||||
included (without `-a` it shows only open items). Follow that work rather than creating it again.
|
||||
Adding OpenRig's MCP plugin to this app later starts a new session; it doesn't carry this conversation over.
|
||||
- **Other ways to work with the team:** `rig send <seat>@<rig> "..."` types into an agent's terminal,
|
||||
`rig capture <seat>@<rig>` reads its screen, and `rig ps --nodes --rig <rig>` shows who's on it.
|
||||
|
||||
|
||||
Reference in new issue
Block a user