mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5244ec3b | ||
|
|
a3cc6c8a10 | ||
|
|
7d77d73b9c | ||
|
|
eef01465a3 | ||
|
|
1facb4485f | ||
|
|
f43bd4e32b | ||
|
|
b10a651fc5 | ||
|
|
9f62a25727 | ||
|
|
bfa66821ea | ||
|
|
bee4be8cad | ||
|
|
fa770114d7 | ||
|
|
cdf5fff724 | ||
|
|
2174b11e5d | ||
|
|
0a22ea184c |
No files matched your search
@@ -8,6 +8,105 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.6.4-rc.1]
|
||||
|
||||
Release candidate for 0.6.4, cut from main at `afde814f`. This is a pre-release
|
||||
candidate, not a stable release. Only admitted blocker fixes change it before
|
||||
0.6.4. These notes make no test claims; the final verification summary will
|
||||
state the tested environments and known limits. The changes since 0.6.3:
|
||||
|
||||
### Release-candidate blocker fixes
|
||||
|
||||
- Keep binary files byte-identical in rig bundles
|
||||
([#245](https://github.com/mvschwarz/openrig/issues/245),
|
||||
[#257](https://github.com/mvschwarz/openrig/pull/257)).
|
||||
- Send local commands to the daemon's recorded endpoint, so a daemon started
|
||||
with `rig daemon start --port` is the one they reach
|
||||
([#246](https://github.com/mvschwarz/openrig/issues/246),
|
||||
[#266](https://github.com/mvschwarz/openrig/pull/266)).
|
||||
- Relaunch a rig's only seat after `rig seat launch --fresh --stop`, instead of
|
||||
refusing once stopping it had ended the tmux server
|
||||
([#265](https://github.com/mvschwarz/openrig/issues/265),
|
||||
[#267](https://github.com/mvschwarz/openrig/pull/267)).
|
||||
|
||||
### Messaging and delivery
|
||||
|
||||
- Distinguish an unverified runtime from a stopped agent when delivering
|
||||
([#240](https://github.com/mvschwarz/openrig/pull/240)).
|
||||
- Finish the printable-separator migration for tmux client and cursor reads
|
||||
([#179](https://github.com/mvschwarz/openrig/pull/179)), on top of a
|
||||
shell-free argv execution seam for tmux
|
||||
([#167](https://github.com/mvschwarz/openrig/pull/167)).
|
||||
- Observe native processes independently of the inherited locale
|
||||
([#239](https://github.com/mvschwarz/openrig/pull/239)).
|
||||
- Release SSE subscriptions aborted during replay
|
||||
([#233](https://github.com/mvschwarz/openrig/pull/233)).
|
||||
- Keep CLI request deadlines active through response bodies, and report a body
|
||||
that fails after headers as an unknown outcome
|
||||
([#201](https://github.com/mvschwarz/openrig/pull/201)).
|
||||
|
||||
### Queue, seats and rigs
|
||||
|
||||
- Keep a blocked row's park timer through a seat handover, and retire a periodic
|
||||
park timer when its row is rerouted
|
||||
([#242](https://github.com/mvschwarz/openrig/pull/242)). Thanks to
|
||||
[@korallis](https://github.com/korallis), who found and documented this in
|
||||
[korallis/agent-stack#61](https://github.com/korallis/agent-stack/pull/61).
|
||||
- Derive terminal pickup for closed queue items
|
||||
([#176](https://github.com/mvschwarz/openrig/pull/176)).
|
||||
- Preserve discovery claims during async rescans
|
||||
([#237](https://github.com/mvschwarz/openrig/pull/237)).
|
||||
- Report an unknown seat launch outcome on timeout
|
||||
([#191](https://github.com/mvschwarz/openrig/pull/191)).
|
||||
- Accept runtime guards for existing nodes when attaching
|
||||
([#228](https://github.com/mvschwarz/openrig/pull/228)).
|
||||
- Give kernel Claude seats the shared activity hooks
|
||||
([#178](https://github.com/mvschwarz/openrig/pull/178)).
|
||||
- Preserve an unset native Claude config selection
|
||||
([#225](https://github.com/mvschwarz/openrig/pull/225)).
|
||||
- Replace a stopped same-name rig generation on YAML import by archiving it,
|
||||
with its ID and unarchive command shown
|
||||
([#196](https://github.com/mvschwarz/openrig/pull/196)).
|
||||
|
||||
### Files, transfer and context
|
||||
|
||||
- Decode SSH and rsync output across UTF-8 chunks
|
||||
([#230](https://github.com/mvschwarz/openrig/pull/230)), and keep UTF-8
|
||||
intact across herdr socket chunks
|
||||
([#241](https://github.com/mvschwarz/openrig/pull/241)).
|
||||
- Preserve local rsync directory content operands
|
||||
([#231](https://github.com/mvschwarz/openrig/pull/231)).
|
||||
- Preserve executable mode during atomic file edits
|
||||
([#235](https://github.com/mvschwarz/openrig/pull/235)).
|
||||
- Project file-shaped entries instead of failing them on ENOTDIR
|
||||
([#185](https://github.com/mvschwarz/openrig/pull/185)).
|
||||
- Clamp oversized suffix ranges to asset bounds
|
||||
([#234](https://github.com/mvschwarz/openrig/pull/234)).
|
||||
- Keep fenced example headings out of context addresses
|
||||
([#236](https://github.com/mvschwarz/openrig/pull/236)).
|
||||
- Disclose stale environment service receipts in `rig env status`
|
||||
([#232](https://github.com/mvschwarz/openrig/pull/232)).
|
||||
|
||||
### Platform and setup
|
||||
|
||||
- Provider-aware Codex readiness for non-OpenAI providers
|
||||
([#222](https://github.com/mvschwarz/openrig/pull/222)).
|
||||
- Leave user config content untouched when stripping Codex activity hooks
|
||||
([#186](https://github.com/mvschwarz/openrig/pull/186)).
|
||||
- Skip `ps` for daemon process liveness on Windows
|
||||
([#173](https://github.com/mvschwarz/openrig/pull/173)).
|
||||
- Make module URLs and transcript containment portable
|
||||
([#168](https://github.com/mvschwarz/openrig/pull/168)).
|
||||
- Keep `ps` rows whose command name contains spaces
|
||||
([#82](https://github.com/mvschwarz/openrig/pull/82)).
|
||||
|
||||
### Repository
|
||||
|
||||
- Skip release-surface tag diffs when old tags are absent
|
||||
([#152](https://github.com/mvschwarz/openrig/pull/152)), and ask security
|
||||
and integration PRs for practical scenarios
|
||||
([#224](https://github.com/mvschwarz/openrig/pull/224)).
|
||||
|
||||
## [0.6.3]
|
||||
|
||||
- Recognize running managed Claude Code seats behind OpenRig's shell wrappers
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
## Browser access and allowed addresses
|
||||
|
||||
The OpenRig daemon answers its API only when a request is addressed to this machine and, if it
|
||||
comes from a web page, only when that page is the OpenRig UI or a page you allowed.
|
||||
|
||||
### What works with no setup
|
||||
|
||||
- The CLI, the TUI and agents on this machine, addressing the daemon by `localhost` or an IP
|
||||
address.
|
||||
- Other OpenRig hosts and clients that reach this daemon by its IP address, its own hostname, or
|
||||
its own Tailscale name (for example `my-machine.your-tailnet.ts.net`, or the short `my-machine`).
|
||||
The daemon learns its own Tailscale name automatically from Tailscale on this machine.
|
||||
- The web UI, when it is turned on (`rig config set ui.enabled true`, then restart the daemon) and
|
||||
opened at the daemon's own address and port.
|
||||
|
||||
### When you see a refusal
|
||||
|
||||
The daemon answers `403` with a sentence that says what to do. There are two kinds:
|
||||
|
||||
- **The address is not recognized** (`untrusted_host`). You reached the daemon by a name it does not
|
||||
know as its own, such as a custom DNS name, an alias in `/etc/hosts`, or a reverse proxy domain.
|
||||
Use `localhost`, an IP address or the machine's own name instead, or list the name you use:
|
||||
`OPENRIG_ALLOWED_HOSTS=rig.example.com` in the daemon's environment, then restart the daemon.
|
||||
A client that presents the daemon's configured bearer token is not refused for its address
|
||||
(it still needs whatever access each route requires).
|
||||
- **The web page is not allowed** (`browser_origin_refused`). A browser page that is not the
|
||||
OpenRig UI at the daemon's own address called the API. This includes another local app or
|
||||
development server on a different port. To allow a page, list its exact origin:
|
||||
`OPENRIG_ALLOWED_ORIGINS=http://localhost:5173` in the daemon's environment, then restart the
|
||||
daemon. A bearer token does not override this.
|
||||
|
||||
`OPENRIG_ALLOWED_HOSTS` lists names you use to reach the daemon. `OPENRIG_ALLOWED_ORIGINS` lists
|
||||
web pages allowed to call it. They are separate: a UI served through your own domain usually needs
|
||||
both. Pages on other origins cannot read the refusal; the message is for you, the CLI and agents.
|
||||
|
||||
### Limits
|
||||
|
||||
- If you rename the machine in Tailscale, the daemon keeps accepting its previous name until it
|
||||
restarts or until it next looks its name up after seeing an unrecognized one.
|
||||
- If the daemon cannot reach Tailscale's resolver, its Tailscale name is refused until a later
|
||||
lookup succeeds. `localhost` and IP addresses keep working.
|
||||
- This protects against unknown web pages and unknown names. It is not complete browser isolation:
|
||||
a page on another site can still cause a simple read request to an address the daemon accepts,
|
||||
although it cannot read the answer. Keep the daemon on loopback or your tailnet.
|
||||
@@ -58,6 +58,7 @@ Read [Have your agent configure permissions](getting-started.md#have-your-agent-
|
||||
(`rig context get reference/getting-started.md#have-your-agent-configure-permissions`). Identify the
|
||||
specific prompt or sandbox restriction. Work within the user's chosen permissions; don't switch the whole environment
|
||||
to unrestricted access to clear one prompt.
|
||||
For a `403` naming `untrusted_host` or `browser_origin_refused`, see [Browser access and allowed addresses](browser-access.md).
|
||||
|
||||
### A seat shows attention, waiting or a failed restore
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7013,7 +7013,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7047,7 +7047,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7088,7 +7088,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { readLocalOrigin } from "./local-origin.js";
|
||||
|
||||
@@ -109,6 +109,22 @@ interface DaemonClientOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
|
||||
function localDaemonUrl(): string | undefined {
|
||||
try {
|
||||
// Resolve at construction time: embedders may change OPENRIG_HOME after import.
|
||||
const state = JSON.parse(fs.readFileSync(path.join(getOpenRigHome(), "daemon.json"), "utf-8"));
|
||||
if (!state || !Number.isSafeInteger(state.pid) || state.pid <= 0
|
||||
|| !Number.isInteger(state.port) || state.port < 1 || state.port > 65535
|
||||
|| (state.host !== undefined && (typeof state.host !== "string" || !state.host.trim()))) return undefined;
|
||||
// A stale PID must not redirect reads or writes to another configured daemon.
|
||||
// Keep the recorded endpoint; the request decides reachability, even after exit.
|
||||
return `http://${state.host ?? "127.0.0.1"}:${state.port}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonClient {
|
||||
readonly baseUrl: string;
|
||||
private fetchImpl: typeof fetch = fetch;
|
||||
@@ -157,9 +173,15 @@ export class DaemonClient {
|
||||
if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else {
|
||||
// Resolve from config (env > file > defaults)
|
||||
const config = new ConfigStore().resolve();
|
||||
this.baseUrl = `http://${config.daemon.host}:${config.daemon.port}`;
|
||||
// --port/--host can differ from configured startup defaults. Match the
|
||||
// lifecycle-aware commands by preferring this home's recorded endpoint.
|
||||
const localUrl = localDaemonUrl();
|
||||
if (localUrl) {
|
||||
this.baseUrl = localUrl;
|
||||
} else {
|
||||
const config = new ConfigStore().resolve(); // env > file > defaults
|
||||
this.baseUrl = `http://${config.daemon.host}:${config.daemon.port}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ Keys:
|
||||
files.allowlist name:/abs/path,name:/abs/path
|
||||
progress.scan_roots name:/abs/path,name:/abs/path
|
||||
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
|
||||
agents.* advisor_session, operator_session
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Command } from "commander";
|
||||
import YAML from "yaml";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -458,10 +459,12 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
}
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
const frontmatter = YAML.stringify(header).trimEnd();
|
||||
const content = `---\n${frontmatter}\n---\n\n${body}`;
|
||||
try {
|
||||
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, { encoding: "utf8", flag: opts.replace ? "w" : "wx" });
|
||||
if (opts.replace) replaceArtifactFile(target, content);
|
||||
else fs.writeFileSync(target, content, { encoding: "utf8", flag: "wx" });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
if (opts.replace || (err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
throw new ScopeCliError({
|
||||
fact: `Proof artifact '${fileName}' already exists.`,
|
||||
consequence: "The existing artifact was not changed.",
|
||||
@@ -506,3 +509,38 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/** Explicit --replace swaps the artifact's directory entry; it never writes through it. A symlink or hard
|
||||
* link at the artifact name therefore keeps its other path's bytes. The staging file is created
|
||||
* exclusively under a unique name, so cleanup only ever removes a file this call created, and a failed
|
||||
* write, close or rename leaves the target as it was. An existing regular artifact's permission bits carry
|
||||
* over, so a replacement never broadens access; a symlinked or new name gets default permissions. The
|
||||
* first failure is the one reported. There is no fsync: this is a same-directory swap, not a
|
||||
* crash-durability guarantee. */
|
||||
export function replaceArtifactFile(target: string, content: string, stagingId: string = randomUUID()): void {
|
||||
const staging = path.join(path.dirname(target), `.${path.basename(target)}.${stagingId}.replace-tmp`);
|
||||
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
|
||||
const keepMode = existing?.isFile() ? existing.mode & 0o777 : undefined;
|
||||
const fd = fs.openSync(staging, "wx", keepMode ?? 0o666); // never created wider than the artifact it replaces
|
||||
let failure: unknown;
|
||||
try {
|
||||
if (keepMode !== undefined) fs.fchmodSync(fd, keepMode);
|
||||
fs.writeFileSync(fd, content, "utf8");
|
||||
} catch (err) {
|
||||
failure = err;
|
||||
}
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch (closeErr) {
|
||||
if (failure === undefined) failure = closeErr;
|
||||
else console.error(`warning: closing staging file ${staging} also failed: ${(closeErr as Error).message}`);
|
||||
}
|
||||
try {
|
||||
if (failure !== undefined) throw failure;
|
||||
fs.renameSync(staging, target);
|
||||
} catch (err) {
|
||||
try { fs.rmSync(staging, { force: true }); }
|
||||
catch (cleanupErr) { console.error(`warning: could not remove staging file ${staging}: ${(cleanupErr as Error).message}`); }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -924,6 +924,8 @@ Exit codes:
|
||||
const res = await client.get<PsEntry[]>("/api/ps?includeArchived=true");
|
||||
|
||||
if (res.status >= 400) {
|
||||
const reason = (res.data as unknown as { error?: unknown } | null)?.error;
|
||||
if (typeof reason === "string" && reason) console.error(reason);
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${res.status}). Check daemon status with: rig status`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
@@ -1077,6 +1079,8 @@ async function handleNodes(
|
||||
): Promise<void> {
|
||||
const rigRes = await client.get<PsEntry[]>(psApiPath(opts), requestHeaders ? { headers: requestHeaders } : undefined);
|
||||
if (rigRes.status >= 400) {
|
||||
const reason = (rigRes.data as unknown as { error?: unknown } | null)?.error;
|
||||
if (typeof reason === "string" && reason) console.error(reason);
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
|
||||
@@ -482,7 +482,11 @@ Examples:
|
||||
const route = `/api/seat/${path}/${encodeURIComponent(seat)}`;
|
||||
res = path === "launch"
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
|
||||
: await client.post<Record<string, unknown>>(route, body);
|
||||
// #260: a dynamic Claude mode waits up to 5 s for the capability query before the
|
||||
// daemon answers, so the 5 s default deadline would abort before its refusal arrives.
|
||||
: path === "set-permissions"
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 10_000 })
|
||||
: await client.post<Record<string, unknown>>(route, body);
|
||||
} catch (err) {
|
||||
if (path !== "launch" || !(err instanceof DaemonTimeoutError)) throw err;
|
||||
const error = {
|
||||
@@ -710,12 +714,18 @@ export async function runSeatHandover(seat: string, opts: HandoverActionOpts, de
|
||||
if (!daemonStatusGuard(daemon)) return; // B8-1b: epistemic-matched
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
|
||||
const handoverRoute = `/api/seat/handover/${encodeURIComponent(seat)}`;
|
||||
const handoverBody = {
|
||||
source: opts.source,
|
||||
reason: opts.reason,
|
||||
operator: opts.operator,
|
||||
dryRun: opts.dryRun === true,
|
||||
});
|
||||
};
|
||||
// #260: a mutating handover launches and readies the successor, so it gets the
|
||||
// launch request window. A dry run only plans, and keeps the default deadline.
|
||||
const res = opts.dryRun === true
|
||||
? await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody)
|
||||
: await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
|
||||
@@ -6,8 +6,13 @@ import { realDeps } from "./daemon.js";
|
||||
export interface UiDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
exec: (cmd: string, args: string[]) => Promise<void>;
|
||||
/** Reads the daemon's own answer for the UI page. Defaults to fetch with a short timeout. */
|
||||
probeUi?: (url: string) => Promise<{ status: number; headers: { get(name: string): string | null } }>;
|
||||
}
|
||||
|
||||
export const UI_DISABLED_GUIDANCE =
|
||||
"The OpenRig web UI is off on this daemon. To turn it on, run `rig config set ui.enabled true`, then stop and start the daemon (`rig daemon stop`, `rig daemon start`).";
|
||||
|
||||
export const UI_MAINTENANCE_NOTICE =
|
||||
"The OpenRig UI is experimental and in maintenance mode. It is not under active development; support is best-effort. The CLI is the primary supported interface. Contributions welcome.";
|
||||
|
||||
@@ -47,6 +52,18 @@ export function uiCommand(depsOverride?: UiDeps): Command {
|
||||
if (!daemonStatusGuard(status)) return;
|
||||
|
||||
const url = getDaemonUrl(status);
|
||||
// Ask the running daemon, not local config: the setting takes effect when the daemon starts.
|
||||
const probeUi = deps.probeUi ?? ((target: string) => fetch(target, { signal: AbortSignal.timeout(3000) }));
|
||||
try {
|
||||
const res = await probeUi(url);
|
||||
if (res.status === 404 && res.headers.get("x-openrig-web-ui") === "off") {
|
||||
console.error(UI_DISABLED_GUIDANCE);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No answer: open the URL as before; the browser shows whatever the daemon serves.
|
||||
}
|
||||
console.log(url);
|
||||
|
||||
try {
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface RiggedConfig {
|
||||
// Preview Terminal v0 (PL-018) — UI-side preferences for the live
|
||||
// terminal preview pane.
|
||||
ui: {
|
||||
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
|
||||
enabled: boolean;
|
||||
timezone: string;
|
||||
preview: {
|
||||
refreshIntervalSeconds: number;
|
||||
@@ -239,6 +241,7 @@ const DEFAULTS = {
|
||||
files: { allowlist: "" },
|
||||
progress: { scanRoots: "" },
|
||||
ui: {
|
||||
enabled: false,
|
||||
timezone: "America/Los_Angeles",
|
||||
preview: {
|
||||
refreshIntervalSeconds: 3,
|
||||
@@ -377,6 +380,7 @@ export const VALID_KEYS = [
|
||||
"ui.preview.max_pins",
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
"ui.enabled",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
@@ -471,6 +475,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
@@ -551,6 +556,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
@@ -999,6 +1005,7 @@ export class ConfigStore {
|
||||
scanRoots: v("progress.scan_roots") as string,
|
||||
},
|
||||
ui: {
|
||||
enabled: v("ui.enabled") as boolean,
|
||||
timezone: v("ui.timezone") as string,
|
||||
preview: {
|
||||
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// The real `rig ps` / `rig send` code paths, real DaemonClient and real HTTP, rendering the exact
|
||||
// refusal body produced by the daemon's browser boundary. (A real CLI request cannot present an
|
||||
// unlisted target name without DNS: Node fetch sends the URL's host, not an explicit Host header.)
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Command } from "commander";
|
||||
import { Hono } from "hono";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import { psCommand } from "../src/commands/ps.js";
|
||||
import { sendCommand } from "../src/commands/send.js";
|
||||
import { browserBoundary } from "../../daemon/src/middleware/browser-boundary.js";
|
||||
|
||||
let refusalBody = "";
|
||||
let server: http.Server;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const daemon = new Hono();
|
||||
daemon.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], warn: () => {} }));
|
||||
daemon.all("/api/*", (c) => c.json({ ok: true }));
|
||||
const res = await daemon.request("/api/ps", { headers: { Host: "unlisted.example:7433" } });
|
||||
refusalBody = await res.text();
|
||||
server = http.createServer((_req, res2) => { res2.writeHead(403, { "content-type": "application/json" }); res2.end(refusalBody); });
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterAll(async () => { await new Promise<void>((r) => server.close(() => r())); });
|
||||
afterEach(() => { process.exitCode = undefined; });
|
||||
|
||||
function deps() {
|
||||
const lifecycleDeps: LifecycleDeps = {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => p === STATE_FILE
|
||||
? JSON.stringify({ pid: process.pid, port, db: "test.sqlite", startedAt: "2026-10-01T00:00:00Z" } as DaemonState)
|
||||
: null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
return { lifecycleDeps, clientFactory: (url: string) => new DaemonClient(url) };
|
||||
}
|
||||
|
||||
async function stderrOf(cmd: Command, argv: string[]): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
const orig = console.error;
|
||||
console.error = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); };
|
||||
try { await cmd.parseAsync(["node", "rig", ...argv]); } catch { /* exitOverride */ } finally { console.error = orig; }
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("CLI shows the boundary's remedy sentence", () => {
|
||||
it("the fixture body is the real boundary refusal", () => {
|
||||
expect(JSON.parse(refusalBody)).toMatchObject({ code: "untrusted_host" });
|
||||
});
|
||||
|
||||
it("rig ps prints the daemon's sentence before its generic line", async () => {
|
||||
const prog = new Command().exitOverride();
|
||||
prog.addCommand(psCommand(deps() as never));
|
||||
const err = await stderrOf(prog, ["ps"]);
|
||||
expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
expect(err).toContain("Failed to fetch rig list from daemon (HTTP 403)");
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("rig send already prints the daemon's error sentence (no change needed)", async () => {
|
||||
const prog = new Command().exitOverride();
|
||||
prog.addCommand(sendCommand(deps() as never));
|
||||
const err = await stderrOf(prog, ["send", "dev-impl@rig", "fixture"]);
|
||||
expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonClient, remoteDaemonClient } from "../src/client.js";
|
||||
import { proofCommand } from "../src/commands/proof.js";
|
||||
import { parkedCommand } from "../src/commands/parked.js";
|
||||
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
|
||||
|
||||
describe("default client endpoint selection", () => {
|
||||
let root: string;
|
||||
let home: string;
|
||||
const servers: http.Server[] = [];
|
||||
const fallback = "http://configured.invalid:28123";
|
||||
const state = (port = 27451, extra = {}) => ({ pid: process.pid, port, host: "127.0.0.1", ...extra });
|
||||
const writeState = (value: unknown, target = home) => fs.writeFileSync(path.join(target, "daemon.json"), JSON.stringify(value));
|
||||
const configure = (target = home) => fs.writeFileSync(path.join(target, "config.json"), JSON.stringify({ daemon: { host: "configured.invalid", port: 28123 } }));
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "client-endpoint-"));
|
||||
home = path.join(root, "a");
|
||||
fs.mkdirSync(home);
|
||||
for (const key of ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_HOST", "RIGGED_HOST", "OPENRIG_PORT", "RIGGED_PORT", "RIGGED_HOME", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME", "OPENRIG_TERMINAL_BEARER_TOKEN"]) vi.stubEnv(key, undefined);
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
configure();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => server.close(e => e ? reject(e) : resolve()));
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
resetFetchAllowlist();
|
||||
vi.unstubAllEnvs();
|
||||
process.exitCode = 0;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses the actual live-home endpoint ahead of configured startup defaults without rewriting either file", () => {
|
||||
writeState(state());
|
||||
const before = fs.readFileSync(path.join(home, "daemon.json"));
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
expect(fs.readFileSync(path.join(home, "daemon.json"))).toEqual(before);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8")).daemon.port).toBe(28123);
|
||||
});
|
||||
|
||||
it("preserves explicit baseUrl, primary URL and legacy URL precedence over state", () => {
|
||||
writeState(state());
|
||||
vi.stubEnv("RIGGED_URL", "http://legacy.invalid:28001");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://legacy.invalid:28001");
|
||||
vi.stubEnv("OPENRIG_URL", "http://explicit.invalid:28002");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://explicit.invalid:28002");
|
||||
expect(new DaemonClient("http://argument.invalid:28003").baseUrl).toBe("http://argument.invalid:28003");
|
||||
});
|
||||
|
||||
it("uses host/port config and its env overrides when no state exists", () => {
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
vi.stubEnv("OPENRIG_HOST", "env.invalid");
|
||||
vi.stubEnv("OPENRIG_PORT", "28004");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://env.invalid:28004");
|
||||
vi.stubEnv("OPENRIG_HOST", undefined);
|
||||
vi.stubEnv("OPENRIG_PORT", undefined);
|
||||
vi.stubEnv("RIGGED_HOST", "legacy-config.invalid");
|
||||
vi.stubEnv("RIGGED_PORT", "28005");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://legacy-config.invalid:28005");
|
||||
});
|
||||
|
||||
it("uses the existing default when both state and config are absent (no network request)", () => {
|
||||
fs.unlinkSync(path.join(home, "config.json"));
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:7433");
|
||||
});
|
||||
|
||||
it("keeps a live endpoint when startup host/port configuration changes", () => {
|
||||
writeState(state());
|
||||
vi.stubEnv("OPENRIG_PORT", "28004");
|
||||
vi.stubEnv("OPENRIG_HOST", "future.invalid");
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
});
|
||||
|
||||
it("keeps the recorded endpoint for a dead PID without rewriting state", () => {
|
||||
writeState(state());
|
||||
const before = fs.readFileSync(path.join(home, "daemon.json"));
|
||||
vi.spyOn(process, "kill").mockImplementation(() => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); });
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
expect(fs.readFileSync(path.join(home, "daemon.json"))).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not mistake a permission-denied liveness check for a dead endpoint", () => {
|
||||
writeState(state());
|
||||
vi.spyOn(process, "kill").mockImplementation(() => { throw Object.assign(new Error("denied"), { code: "EPERM" }); });
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
});
|
||||
|
||||
it.each([null, [], {}, { pid: -1, port: 27451 }, { pid: "12", port: 27451 }, { pid: process.pid, port: "27451" }, { pid: process.pid, port: 0 }, { pid: process.pid, port: 65536 }, { pid: process.pid, port: 1.5 }, { pid: process.pid, port: 27451, host: 12 }])("falls back on unusable state shape: %j", value => {
|
||||
writeState(value);
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
});
|
||||
|
||||
it("falls back on malformed JSON", () => {
|
||||
fs.writeFileSync(path.join(home, "daemon.json"), "{");
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
});
|
||||
|
||||
it("supports old state without a host and honors a persisted custom host", () => {
|
||||
writeState({ pid: process.pid, port: 27451 });
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
writeState(state(27451, { host: "custom.invalid" }));
|
||||
expect(new DaemonClient().baseUrl).toBe("http://custom.invalid:27451");
|
||||
});
|
||||
|
||||
it("resolves home per construction, accepts RIGGED_HOME, and never discovers a sibling", () => {
|
||||
writeState(state());
|
||||
const first = new DaemonClient();
|
||||
const other = path.join(root, "b");
|
||||
fs.mkdirSync(other);
|
||||
configure(other);
|
||||
vi.stubEnv("OPENRIG_HOME", other);
|
||||
expect(new DaemonClient().baseUrl).toBe(fallback);
|
||||
writeState(state(27452), other);
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27452");
|
||||
expect(first.baseUrl).toBe("http://127.0.0.1:27451");
|
||||
vi.stubEnv("OPENRIG_HOME", undefined);
|
||||
vi.stubEnv("RIGGED_HOME", home);
|
||||
expect(new DaemonClient().baseUrl).toBe("http://127.0.0.1:27451");
|
||||
});
|
||||
|
||||
it("preserves remote destination and origin identity while local state exists", async () => {
|
||||
writeState(state());
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "author@fixture");
|
||||
const fetchImpl = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => new Response("{}"));
|
||||
const client = remoteDaemonClient(url => new DaemonClient(url, { fetchImpl }), "http://remote.invalid:28006", "origin-fixture");
|
||||
await client.post("/api/proof/judge", { fixture: true });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl.mock.calls[0]).toEqual(["http://remote.invalid:28006/api/proof/judge", expect.objectContaining({ headers: expect.objectContaining({ "X-OpenRig-Session": "author@fixture@origin-fixture" }) })]);
|
||||
});
|
||||
|
||||
it("routes actual proof reads, intercepted judgments and parked to each selected home's server", async () => {
|
||||
const received: Array<{ server: string; method: string; url: string; body: unknown; sender?: string }> = [];
|
||||
const start = async (name: string, port: number) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let text = "";
|
||||
for await (const chunk of req) text += chunk;
|
||||
received.push({ server: name, method: req.method!, url: req.url!, body: text ? JSON.parse(text) : null, sender: req.headers["x-openrig-session"] as string | undefined });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(req.url?.startsWith("/api/proof?")
|
||||
? { items: [{ id: "fixture-item", index: 1, text: "fixture", revision: "r1", judgment: null }] }
|
||||
: { ok: true, fixture: name }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); });
|
||||
servers.push(server);
|
||||
const actualPort = (server.address() as { port: number }).port;
|
||||
allowFetchTarget(`http://127.0.0.1:${actualPort}`);
|
||||
return actualPort;
|
||||
};
|
||||
const a = await start("A", Number(process.env.F1_SERVER_A_PORT ?? 0));
|
||||
const b = await start("B", Number(process.env.F1_SERVER_B_PORT ?? 0));
|
||||
const other = path.join(root, "b"); fs.mkdirSync(other);
|
||||
writeState(state(a)); writeState(state(b), other);
|
||||
// Deliberately conflicting startup config exposes a wrong-home write on the old client.
|
||||
fs.writeFileSync(path.join(home, "config.json"), JSON.stringify({ daemon: { host: "127.0.0.1", port: b } }));
|
||||
fs.writeFileSync(path.join(other, "config.json"), JSON.stringify({ daemon: { host: "127.0.0.1", port: a } }));
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "author@fixture");
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const selected: string[] = [];
|
||||
for (const target of [home, other]) {
|
||||
vi.stubEnv("OPENRIG_HOME", target);
|
||||
selected.push(new DaemonClient().baseUrl);
|
||||
await proofCommand().parseAsync(["show", "fixture", "--json"], { from: "user" });
|
||||
await proofCommand().parseAsync(["judge", "fixture#1", "--verdict", "accept", "--reason", "routing fixture only", "--json"], { from: "user" });
|
||||
await parkedCommand().parseAsync(["author@fixture", "--json"], { from: "user" });
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
}
|
||||
process.stdout.write(`F1 routing receipt ${JSON.stringify({ endpoints: { A: a, B: b }, selected, received })}\n`);
|
||||
expect(selected).toEqual([`http://127.0.0.1:${a}`, `http://127.0.0.1:${b}`]);
|
||||
expect(received.map(r => [r.server, r.method, r.url])).toEqual(["A", "B"].flatMap(name => [
|
||||
[name, "GET", "/api/proof?scope=fixture"], [name, "GET", "/api/proof?scope=fixture"],
|
||||
[name, "POST", "/api/proof/judge"], [name, "GET", "/api/activity/parked?seat=author%40fixture"],
|
||||
]));
|
||||
expect(received.filter(r => r.method === "POST").map(r => r.body)).toEqual(["A", "B"].map(() => expect.objectContaining({ item: "fixture-item", expectedRevision: "r1", verdict: "accept" })));
|
||||
expect(received.every(r => r.sender === "author@fixture")).toBe(true);
|
||||
|
||||
// S1: the recorded daemon stops while the configured other daemon remains up.
|
||||
const stopped = servers.shift()!;
|
||||
stopped.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => stopped.close(e => e ? reject(e) : resolve()));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.spyOn(process, "kill").mockImplementation(() => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); });
|
||||
const staleClient = new DaemonClient();
|
||||
const count = received.length;
|
||||
const results = await Promise.allSettled([staleClient.get("/api/proof"), staleClient.post("/api/proof/judge", { fixture: "stale" })]);
|
||||
process.stdout.write(`F1 stale routing receipt ${JSON.stringify({ selected: staleClient.baseUrl, stoppedPort: a, otherPort: b, results: results.map(r => r.status === "rejected" ? { status: r.status, error: String(r.reason) } : r), extraRequests: received.slice(count) })}\n`);
|
||||
expect(staleClient.baseUrl).toBe(`http://127.0.0.1:${a}`);
|
||||
for (const result of results) {
|
||||
expect(result.status).toBe("rejected");
|
||||
if (result.status === "rejected") expect(String(result.reason)).toContain(`Cannot connect to the OpenRig daemon at http://127.0.0.1:${a}`);
|
||||
}
|
||||
expect(received).toHaveLength(count); // no read or judgment reaches B
|
||||
});
|
||||
});
|
||||
@@ -180,7 +180,7 @@ describe("DaemonClient", () => {
|
||||
cwd: resolve(import.meta.dirname, ".."),
|
||||
});
|
||||
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?: \([0-9a-f]{8}(?:, dirty)?\))?$/);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: \([0-9a-f]{8}(?:, dirty)?\))?$/);
|
||||
});
|
||||
|
||||
// Test 7: Client non-2xx (409) -> returns { status: 409, data: errorBody }
|
||||
|
||||
@@ -132,6 +132,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"health.context_pressure.critical_percent",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
|
||||
"ui.enabled",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
|
||||
|
||||
@@ -8,6 +8,7 @@ import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
proofCommand,
|
||||
replaceArtifactFile,
|
||||
validateC1Header,
|
||||
parseProofContract,
|
||||
C1_ARTIFACT_TYPES,
|
||||
@@ -430,3 +431,211 @@ describe("proof add — pristine-scaffold contract never canonical (KI-5.3-2 sec
|
||||
expect((out.contractItemsCovered ?? []).join(" ")).toContain("REAL ITEM ONE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig proof add --replace swaps the entry and never writes through links", () => {
|
||||
let workRoot: string;
|
||||
let sliceDir: string;
|
||||
let proofDir: string;
|
||||
let errs: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "proof-replace-"));
|
||||
sliceDir = path.join(workRoot, "missions", "release-x", "slices", "19-signal-layer");
|
||||
proofDir = path.join(sliceDir, "proof");
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(workRoot, "missions", "release-x", "README.md"), "---\nid: OPR.X\n---\n# m\n");
|
||||
fs.writeFileSync(path.join(sliceDir, "README.md"), "---\nid: OPR.X.19\nstatus: building\n---\n# slice\n");
|
||||
errs = [];
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.join(" ")); });
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(workRoot, { recursive: true, force: true });
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
async function add(name: string, extra: string[] = []): Promise<void> {
|
||||
const cmd = proofCommand();
|
||||
cmd.exitOverride();
|
||||
await cmd.parseAsync(["node", "proof", "--workspace", workRoot, "add", "19-signal-layer", "--mission", "release-x",
|
||||
"--artifact-type", "qa", "--verdict", "CLEAR", "--candidate-sha", "abc1234", "--money-evidence", "m",
|
||||
"--name", name, ...(extra.includes("--file") ? [] : ["--body", "replacement body"]), ...extra]);
|
||||
}
|
||||
const stagingLeftovers = () => fs.readdirSync(proofDir).filter((f) => f.endsWith(".replace-tmp"));
|
||||
const png = (tag: string) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from(tag)]);
|
||||
|
||||
it("a symlinked artifact name is replaced; the linked file keeps its bytes", async () => {
|
||||
const outside = path.join(workRoot, "outside");
|
||||
fs.mkdirSync(outside);
|
||||
const victim = path.join(outside, "victim.png");
|
||||
fs.writeFileSync(victim, png("symlink victim"));
|
||||
fs.symlinkSync(victim, path.join(proofDir, "linked.md"));
|
||||
await add("linked.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(victim)).toEqual(png("symlink victim"));
|
||||
expect(fs.lstatSync(path.join(proofDir, "linked.md")).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(proofDir, "linked.md"), "utf8")).toContain("replacement body");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a hard-linked artifact name is replaced; the other link keeps its bytes", async () => {
|
||||
const shot = path.join(proofDir, "shot.png");
|
||||
fs.writeFileSync(shot, png("hardlink victim"));
|
||||
fs.linkSync(shot, path.join(proofDir, "hard.md"));
|
||||
await add("hard.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(shot)).toEqual(png("hardlink victim"));
|
||||
expect(fs.statSync(shot).nlink).toBe(1);
|
||||
expect(fs.readFileSync(path.join(proofDir, "hard.md"), "utf8")).toContain("replacement body");
|
||||
});
|
||||
|
||||
it("a normal replace and a self-sourced replace both still work", async () => {
|
||||
fs.writeFileSync(path.join(proofDir, "normal.md"), "original\n");
|
||||
await add("normal.md", ["--replace"]);
|
||||
expect(fs.readFileSync(path.join(proofDir, "normal.md"), "utf8")).toContain("replacement body");
|
||||
const self = path.join(proofDir, "self.md");
|
||||
fs.writeFileSync(self, "self body\n");
|
||||
await add("self.md", ["--file", self, "--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const after = fs.readFileSync(self, "utf8");
|
||||
expect(after.startsWith("---\n")).toBe(true);
|
||||
expect(after).toContain("self body");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("without --replace an existing name is still refused and unchanged", async () => {
|
||||
const existing = path.join(proofDir, "kept.md");
|
||||
fs.writeFileSync(existing, "kept\n");
|
||||
await add("kept.md");
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errs.join("\n")).toContain("already exists");
|
||||
expect(fs.readFileSync(existing, "utf8")).toBe("kept\n");
|
||||
});
|
||||
|
||||
it("never touches a staging file it did not create", () => {
|
||||
const target = path.join(proofDir, "x.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const foreign = path.join(proofDir, ".x.md.fixed.replace-tmp");
|
||||
fs.writeFileSync(foreign, "not ours\n");
|
||||
expect(() => replaceArtifactFile(target, "new\n", "fixed")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.readFileSync(foreign, "utf8")).toBe("not ours\n");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
});
|
||||
|
||||
it("a failed write leaves the target as it was and removes only its own staging file", async () => {
|
||||
const target = path.join(proofDir, "w.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const real = fs.writeFileSync;
|
||||
vi.spyOn(fs, "writeFileSync").mockImplementation(((file: unknown, ...rest: unknown[]) => {
|
||||
if (typeof file === "number") throw Object.assign(new Error("no space left"), { code: "ENOSPC" });
|
||||
return (real as (...a: unknown[]) => void)(file, ...rest);
|
||||
}) as typeof fs.writeFileSync);
|
||||
await expect(add("w.md", ["--replace"])).rejects.toMatchObject({ code: "ENOSPC" });
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a directory at the artifact name fails the replace and is left intact", async () => {
|
||||
const dir = path.join(proofDir, "dir.md");
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, "inside.txt"), "inside\n");
|
||||
await expect(add("dir.md", ["--replace"])).rejects.toMatchObject({ code: expect.stringMatching(/^E/) });
|
||||
expect(fs.statSync(dir).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(dir, "inside.txt"), "utf8")).toBe("inside\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an existing regular artifact's permission bits; a symlinked name gets default permissions", async () => {
|
||||
const priv = path.join(proofDir, "private.md");
|
||||
fs.writeFileSync(priv, "original\n");
|
||||
fs.chmodSync(priv, 0o600);
|
||||
const inode = fs.statSync(priv).ino;
|
||||
await add("private.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.statSync(priv).mode & 0o777).toBe(0o600);
|
||||
expect(fs.statSync(priv).ino).not.toBe(inode);
|
||||
expect(fs.readFileSync(priv, "utf8")).toContain("replacement body");
|
||||
|
||||
const outside = path.join(workRoot, "outside-mode");
|
||||
fs.mkdirSync(outside);
|
||||
const linkedTarget = path.join(outside, "secret.png");
|
||||
fs.writeFileSync(linkedTarget, png("mode victim"));
|
||||
fs.chmodSync(linkedTarget, 0o600);
|
||||
fs.symlinkSync(linkedTarget, path.join(proofDir, "via-link.md"));
|
||||
await add("via-link.md", ["--replace"]);
|
||||
expect(fs.lstatSync(path.join(proofDir, "via-link.md")).mode & 0o777).toBe(0o666 & ~process.umask());
|
||||
expect(fs.statSync(linkedTarget).mode & 0o777).toBe(0o600);
|
||||
expect(fs.readFileSync(linkedTarget)).toEqual(png("mode victim"));
|
||||
});
|
||||
|
||||
it("creates the staging file no wider than the artifact it replaces", async () => {
|
||||
const priv = path.join(proofDir, "tight.md");
|
||||
fs.writeFileSync(priv, "original\n");
|
||||
fs.chmodSync(priv, 0o600);
|
||||
const createdModes: number[] = [];
|
||||
const realFchmod = fs.fchmodSync;
|
||||
vi.spyOn(fs, "fchmodSync").mockImplementation(((fd: number, mode: fs.Mode) => {
|
||||
createdModes.push(fs.fstatSync(fd).mode & 0o777); // the mode the exclusive open created, before narrowing
|
||||
return realFchmod(fd, mode);
|
||||
}) as typeof fs.fchmodSync);
|
||||
await add("tight.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(createdModes).toEqual([0o600]);
|
||||
expect(fs.statSync(priv).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("replaces a read-only artifact on explicit --replace and keeps it read-only", async () => {
|
||||
const ro = path.join(proofDir, "frozen.md");
|
||||
fs.writeFileSync(ro, "original\n");
|
||||
fs.chmodSync(ro, 0o444);
|
||||
await add("frozen.md", ["--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.statSync(ro).mode & 0o777).toBe(0o444);
|
||||
expect(fs.readFileSync(ro, "utf8")).toContain("replacement body");
|
||||
});
|
||||
|
||||
it("reports the write failure, not a later close failure, and keeps the target", async () => {
|
||||
const target = path.join(proofDir, "wc.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const realWrite = fs.writeFileSync;
|
||||
const realClose = fs.closeSync;
|
||||
vi.spyOn(fs, "writeFileSync").mockImplementation(((file: unknown, ...rest: unknown[]) => {
|
||||
if (typeof file === "number") throw Object.assign(new Error("no space left"), { code: "ENOSPC" });
|
||||
return (realWrite as (...a: unknown[]) => void)(file, ...rest);
|
||||
}) as typeof fs.writeFileSync);
|
||||
vi.spyOn(fs, "closeSync").mockImplementation(((fd: number) => {
|
||||
realClose(fd);
|
||||
throw Object.assign(new Error("i/o error on close"), { code: "EIO" });
|
||||
}) as typeof fs.closeSync);
|
||||
await expect(add("wc.md", ["--replace"])).rejects.toMatchObject({ code: "ENOSPC" });
|
||||
expect(errs.join("\n")).toContain("also failed");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a close failure alone still refuses the rename", async () => {
|
||||
const target = path.join(proofDir, "c.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
const realClose = fs.closeSync;
|
||||
vi.spyOn(fs, "closeSync").mockImplementation(((fd: number) => {
|
||||
realClose(fd);
|
||||
throw Object.assign(new Error("i/o error on close"), { code: "EIO" });
|
||||
}) as typeof fs.closeSync);
|
||||
await expect(add("c.md", ["--replace"])).rejects.toMatchObject({ code: "EIO" });
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(stagingLeftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the original failure when cleanup also fails", async () => {
|
||||
const target = path.join(proofDir, "r.md");
|
||||
fs.writeFileSync(target, "original\n");
|
||||
vi.spyOn(fs, "renameSync").mockImplementation(() => { throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); });
|
||||
vi.spyOn(fs, "rmSync").mockImplementation(() => { throw Object.assign(new Error("busy"), { code: "EBUSY" }); });
|
||||
await expect(add("r.md", ["--replace"])).rejects.toMatchObject({ code: "EXDEV" });
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("original\n");
|
||||
expect(errs.join("\n")).toContain("could not remove staging file");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe("S03 policy permissions compatibility", () => {
|
||||
exists: (p: string) => p === STATE_FILE, isProcessAlive: () => true, fetch: async () => ({ ok: true }),
|
||||
}, clientFactory: () => ({ post: async (...a: unknown[]) => { posts.push(a); return { status: 409, data: response }; } }) };
|
||||
const result = await capture(seatCommand(deps as never), ["seat", "set-permissions", "owner@inert", "--mode", "auto", "--reason", "user chose", "--json"]);
|
||||
expect(posts).toEqual([["/api/seat/set-permissions/owner%40inert", { mode: "auto", reason: "user chose" }]]);
|
||||
expect(posts).toEqual([["/api/seat/set-permissions/owner%40inert", { mode: "auto", reason: "user chose" }, { timeoutMs: 10_000 }]]);
|
||||
expect(result.exit).toBe(1); expect(JSON.parse(result.logs.join(""))).toEqual(response); expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -744,3 +744,88 @@ describe("rig seat switch-client", () => {
|
||||
expect(errors.join("\n")).toContain("tmux switch-client failed");
|
||||
});
|
||||
});
|
||||
|
||||
// #260: the daemon may hold a dynamic Claude permission request for up to 5 s while it
|
||||
// queries `claude --help`; a mutating handover launches and readies a successor. These
|
||||
// bind the per-call request deadlines with fake timers (no wall-clock bound).
|
||||
describe("seat request deadlines (#260)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const QUERY_REFUSAL = {
|
||||
ok: false,
|
||||
code: "permission_selection_refused",
|
||||
message: "Claude managed capability query failed; no fallback was selected.",
|
||||
};
|
||||
|
||||
function slowClient(respondAfterMs: number | null, response: () => Response) {
|
||||
const fetchImpl = vi.fn((_url: unknown, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
|
||||
const timer = respondAfterMs === null ? undefined : setTimeout(() => resolve(response()), respondAfterMs);
|
||||
init!.signal!.addEventListener("abort", () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(init!.signal!.reason);
|
||||
}, { once: true });
|
||||
}));
|
||||
const deps = makeDeps({ status: 200, data: {} }, []);
|
||||
deps.clientFactory = url => new DaemonClient(url, { fetchImpl });
|
||||
return { deps, fetchImpl };
|
||||
}
|
||||
|
||||
async function run(deps: StatusDeps, argv: string[], advanceMs: number) {
|
||||
const result = captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", ...argv]).then(() => undefined))
|
||||
.catch(error => ({ error: error as Error }));
|
||||
await vi.advanceTimersByTimeAsync(advanceMs);
|
||||
return result;
|
||||
}
|
||||
|
||||
const SET_PERMISSIONS = ["seat", "set-permissions", "dev-impl@seat-rig", "--mode", "auto", "--reason", "slow help", "--json"];
|
||||
const HANDOVER = ["seat", "handover", "dev-impl@seat-rig", "--reason", "context-wall", "--json"];
|
||||
|
||||
it("set-permissions receives the daemon's capability-query refusal after the 5 s default would have aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps, fetchImpl } = slowClient(5_010, () => Response.json(QUERY_REFUSAL, { status: 409 }));
|
||||
const output = await run(deps, SET_PERMISSIONS, 5_010);
|
||||
if (!("logs" in output)) throw output.error;
|
||||
expect(JSON.parse(output.logs.join("\n"))).toMatchObject(QUERY_REFUSAL);
|
||||
expect(output.exitCode).toBe(1);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("set-permissions is still bounded, at 10 s", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps, fetchImpl } = slowClient(null, () => Response.json({}));
|
||||
const output = await run(deps, SET_PERMISSIONS, 10_000);
|
||||
expect(output).toHaveProperty("error");
|
||||
expect((output as { error: Error }).error.message).toContain("timed out after 10000ms");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a mutating handover gets the 120 s launch window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps, fetchImpl } = slowClient(null, () => Response.json({}));
|
||||
const output = await run(deps, HANDOVER, 120_000);
|
||||
expect(output).toHaveProperty("error");
|
||||
expect((output as { error: Error }).error.message).toContain("timed out after 120000ms");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a mutating handover receives a response that arrives after five seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deps } = slowClient(6_000, () => Response.json({ ok: false, code: "handover_refused", message: "synthetic slow refusal" }, { status: 409 }));
|
||||
const output = await run(deps, HANDOVER, 6_000);
|
||||
if (!("logs" in output)) throw output.error;
|
||||
expect(JSON.parse(output.logs.join("\n"))).toMatchObject({ code: "handover_refused" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["dry-run handover", [...HANDOVER, "--dry-run"]],
|
||||
["set-model", ["seat", "set-model", "dev-impl@seat-rig", "--model", "m", "--reason", "r", "--json"]],
|
||||
])("%s keeps the 5 s default deadline", async (_name, argv) => {
|
||||
vi.useFakeTimers();
|
||||
const { deps } = slowClient(null, () => Response.json({}));
|
||||
const output = await run(deps, argv as string[], 5_000);
|
||||
expect(output).toHaveProperty("error");
|
||||
expect((output as { error: Error }).error.message).toContain("timed out after 5000ms");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { createProgram } from "../src/index.js";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
UI_DISABLED_GUIDANCE,
|
||||
UI_MAINTENANCE_NOTICE,
|
||||
uiCommand,
|
||||
type UiDeps,
|
||||
@@ -54,6 +55,8 @@ function runningDeps(port: number, execFn?: UiDeps["exec"]): UiDeps {
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
exec: execFn ?? vi.fn(async () => {}),
|
||||
// The daemon serves the UI unless a test says otherwise; no test reaches a real port.
|
||||
probeUi: vi.fn(async () => ({ status: 200, headers: new Headers({ "content-type": "text/html" }) })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,4 +250,34 @@ describe("rig ui open", () => {
|
||||
else process.env["OPENRIG_UI_URL"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("daemon serving the UI off -> prints the enable and restart steps, exits 1, opens nothing", async () => {
|
||||
const execFn = vi.fn(async () => {});
|
||||
const deps = runningDeps(8888, execFn);
|
||||
deps.probeUi = vi.fn(async () => ({ status: 404, headers: new Headers({ "x-openrig-web-ui": "off" }) }));
|
||||
const prior = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
const program = new Command();
|
||||
program.addCommand(uiCommand(deps));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "ui", "open"]).then(() => undefined));
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = prior;
|
||||
expect(deps.probeUi).toHaveBeenCalledWith("http://127.0.0.1:8888");
|
||||
expect(execFn).not.toHaveBeenCalled();
|
||||
expect(logs).toContain(UI_DISABLED_GUIDANCE);
|
||||
expect(UI_DISABLED_GUIDANCE).toContain("rig config set ui.enabled true");
|
||||
expect(UI_DISABLED_GUIDANCE).toMatch(/rig daemon stop.*rig daemon start/);
|
||||
expect(logs).not.toContain("http://127.0.0.1:8888");
|
||||
});
|
||||
|
||||
it("an unanswered UI probe opens the URL as before", async () => {
|
||||
const execFn = vi.fn(async () => {});
|
||||
const deps = runningDeps(8888, execFn);
|
||||
deps.probeUi = vi.fn(async () => { throw new Error("connection refused"); });
|
||||
const program = new Command();
|
||||
program.addCommand(uiCommand(deps));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "ui", "open"]).then(() => undefined));
|
||||
expect(execFn).toHaveBeenCalledWith("open", ["http://127.0.0.1:8888"]);
|
||||
expect(logs).not.toContain(UI_DISABLED_GUIDANCE);
|
||||
});
|
||||
});
|
||||
@@ -39,11 +39,21 @@ A request can be vague in several directions: diagnose or change, contents or pr
|
||||
local symptom or intended outcome. Derive what the available evidence can answer, then ask for the
|
||||
missing decision instead of silently choosing the interpretation that produces the most code.
|
||||
|
||||
The recurring failure is easy to rationalize. A doghouse seems to need a lock; the lock seems to
|
||||
need power; power suggests more infrastructure. Every step is locally defensible, yet the requested
|
||||
shelter never arrives. The cheapest corrective question is: **How big is the dog?** Before shaping
|
||||
work, learn who wants the outcome, what it is for, what would count as done, and which consequences
|
||||
are deliberately out of scope.
|
||||
The recurring failure is easy to rationalize, and it comes in two shapes. Asked for a doghouse, an
|
||||
agent wonders whether it should have a light. The light needs power, power needs a generator, and the
|
||||
generator needs fuel. Every step is locally defensible, and an hour later there is a moon base while
|
||||
the dog is still waiting outside. The question that stops it: **Does the thing actually need this?**
|
||||
|
||||
The other shape is quieter. The agent builds exactly the doghouse that was asked for, tidy and on
|
||||
time, with a door the dog can't fit through. Nothing was over-built, but nobody asked the one fact
|
||||
that decided whether it works. The question that stops it: **How big is the dog?**
|
||||
|
||||
Both are the same failure: losing the plot. Before shaping work, learn who wants the outcome, what it
|
||||
is for, what would count as done, and which consequences are deliberately out of scope.
|
||||
|
||||
What prevents this is shared understanding of the goal, not tighter instructions. When you hand work
|
||||
to a capable peer, give them the intent, the context and where to find more, and let them decide how.
|
||||
A narrow script brings its own failure: you get exactly what was written, not what was wanted.
|
||||
|
||||
Run `rig context list` to discover whether this rig provides a world pack. If it does, load that
|
||||
pack's fresh profile with `rig context profile <world-pack-ref> --situation fresh`; otherwise,
|
||||
|
||||
@@ -42,16 +42,12 @@ looked like. State scoped absences honestly: “not represented on the surface c
|
||||
than a global absence you did not establish. If another seat must act, transfer durable work rather
|
||||
than printing a summary and disappearing.
|
||||
|
||||
Run `rig context get onboarding-width` and read its `public-what-you-can-do.md` and
|
||||
`public-reference-material.md` members for the shipped capability map and source map. This
|
||||
retrieval belongs to this second onboarding step; it is not an additional walk step.
|
||||
## When you need more
|
||||
|
||||
The diagnostic and routing reflexes in a maintained world profile are especially relevant to
|
||||
orchestrator and planning roles. Builders should keep their assigned boundary and proof standard;
|
||||
do not import judgment-seat ceremony into an implementation lane merely because the examples are
|
||||
available. Run `rig context list`; if this rig provides a world pack, load its fresh profile with
|
||||
`rig context profile <world-pack-ref> --situation fresh`. If it does not, run
|
||||
`rig context get world-example` for a fill-in template showing how to build one. These two
|
||||
onboarding steps plus `onboarding-width` are the complete public default mental model. Use
|
||||
`forming-an-openrig-mental-model` or
|
||||
`openrig-operating-model` at their named trigger moments rather than copying their content elsewhere.
|
||||
When you need to know what OpenRig can do, run `rig context get onboarding-width`: its
|
||||
`public-what-you-can-do.md` is the capability map and `public-reference-material.md` the documentation map. For
|
||||
any one command, `rig <command> --help` on the installed binary is the most current answer.
|
||||
|
||||
If you plan or route work, a project's world pack is worth loading in full. If you build, stay with the outcome you
|
||||
were handed and what counts as done. If you are setting up a world pack for your own project,
|
||||
`rig context get world-example` has a fill-in template.
|
||||
@@ -36,6 +36,12 @@ Before delegating to a blank slate, ask: **would a wrong-but-plausible answer he
|
||||
## It generalizes
|
||||
This is really **"route work to whoever holds the context,"** with subagents as the option for work that needs none. The audience is **every agent** — implementers and QA seats make this call as much as orchestrators do.
|
||||
|
||||
## Hand over intent, not instructions
|
||||
Whoever does the work, give them the goal, why it matters, the context you hold and where to find more, then let
|
||||
them decide how. When you have the requester's own words, pass them on instead of compressing them into a list of
|
||||
steps. A capable agent with the goal builds better than one painting inside a narrow spec, which gets exactly what
|
||||
was written and not what was wanted. Keep exact instructions for the few things that must be exact, and say why.
|
||||
|
||||
## Once you've decided
|
||||
- Decided on subagents for 2+ independent tasks → `dispatching-parallel-agents` (the fan-out mechanics).
|
||||
- Subagents executing a plan's independent tasks → `subagent-driven-development`.
|
||||
|
||||
@@ -122,4 +122,5 @@ and a useful report to the OpenRig team if you are still stuck. If `rig` won't r
|
||||
|
||||
## Need more than what ships here?
|
||||
|
||||
This index covers the **shipped** surface. A dev host carries far more (factory, architecture, PM-craft, studio skills) reached through the host's own routers/codemaps — if you're on a builder host and need something not listed above, that deeper routing is the next hop, not a wall. (Host-scale routing is the subject of the context-routing architecture doc; at product scale, this one file is the whole map.)
|
||||
This index covers the **shipped** surface. Your project, rig or machine may add its own skills and routers. Check the
|
||||
skills your working directory loads before concluding that something doesn't exist.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -122,4 +122,5 @@ and a useful report to the OpenRig team if you are still stuck. If `rig` won't r
|
||||
|
||||
## Need more than what ships here?
|
||||
|
||||
This index covers the **shipped** surface. A dev host carries far more (factory, architecture, PM-craft, studio skills) reached through the host's own routers/codemaps — if you're on a builder host and need something not listed above, that deeper routing is the next hop, not a wall. (Host-scale routing is the subject of the context-routing architecture doc; at product scale, this one file is the whole map.)
|
||||
This index covers the **shipped** surface. Your project, rig or machine may add its own skills and routers. Check the
|
||||
skills your working directory loads before concluding that something doesn't exist.
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
|
||||
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
import { observeClaudePaneProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
|
||||
import { mergeManagedBlock, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { validateClaudeActivityHookDelivery } from "../domain/claude-activity-hooks.js";
|
||||
@@ -58,6 +59,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
private stateDir: string | null;
|
||||
private collectorAssetPath: string | null;
|
||||
private autoDriveProviderPrompts: boolean;
|
||||
private listProcesses?: NativeProcessLister;
|
||||
private autoLaunches = new Map<string, { binding: NodeBinding; token: string; executable?: string; fingerprint?: string }>();
|
||||
readonly claudeManagedLaunch?: ClaudeManagedLaunch;
|
||||
private activityRelayPath: string | null;
|
||||
private claudeHooksManifestPath: string | null;
|
||||
@@ -73,6 +76,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
stateDir?: string;
|
||||
collectorAssetPath?: string;
|
||||
autoDriveProviderPrompts?: boolean;
|
||||
listProcesses?: NativeProcessLister;
|
||||
claudeManagedLaunch?: ClaudeManagedLaunch;
|
||||
/** DI source of the activity-relay.cjs asset (parity with the Codex adapter). */
|
||||
activityRelayPath?: string;
|
||||
@@ -90,6 +94,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
this.stateDir = deps.stateDir ?? null;
|
||||
this.collectorAssetPath = deps.collectorAssetPath ?? null;
|
||||
this.autoDriveProviderPrompts = deps.autoDriveProviderPrompts ?? false;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.claudeManagedLaunch = deps.claudeManagedLaunch;
|
||||
this.activityRelayPath = deps.activityRelayPath ?? null;
|
||||
this.claudeHooksManifestPath = deps.claudeHooksManifestPath ?? null;
|
||||
@@ -221,6 +226,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
binding = { ...binding };
|
||||
this.autoLaunches.delete(binding.nodeId);
|
||||
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
|
||||
if (!binding.tmuxSession) {
|
||||
return { ok: false, error: "No tmux session bound — cannot launch Claude Code harness" };
|
||||
@@ -314,8 +320,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: `Failed to send Enter: ${enterResult.message}` };
|
||||
}
|
||||
|
||||
this.autoLaunches.set(binding.nodeId, { binding, token: opts.resumeToken ?? generatedSessionId!, executable: managed?.executable });
|
||||
if (opts.resumeToken) {
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession);
|
||||
const verification = await this.verifyResumeLaunch(binding);
|
||||
if (!verification.ok) return verification;
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch };
|
||||
}
|
||||
@@ -327,6 +334,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
|
||||
binding = { ...binding };
|
||||
if (!binding.tmuxSession) {
|
||||
return { ready: false, reason: "No tmux session bound" };
|
||||
}
|
||||
@@ -337,11 +345,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
const paneCommand = await this.tmux.getPaneCommand(binding.tmuxSession);
|
||||
const paneContent = (await this.tmux.capturePaneContent(binding.tmuxSession, 40)) ?? "";
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand,
|
||||
paneContent,
|
||||
});
|
||||
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent);
|
||||
|
||||
if (probe.status === "resumed") return { ready: true };
|
||||
return { ready: false, reason: probe.detail, code: probe.code };
|
||||
@@ -359,17 +363,45 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
// -- Private helpers --
|
||||
|
||||
private async verifyResumeLaunch(tmuxSession: string): Promise<HarnessLaunchResult> {
|
||||
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string) {
|
||||
const input = { runtime: "claude-code", paneCommand, paneContent };
|
||||
const probe = assessNativeResumeProbe(input);
|
||||
// Only the new headerless auto-mode case needs this additional observation.
|
||||
// Screen-only callers cannot opt themselves into managed launch readiness.
|
||||
if (probe.code !== "claude_auto_identity_required") return probe;
|
||||
const launch = this.autoLaunches.get(binding.nodeId);
|
||||
if (!launch || !binding.tmuxPane || !binding.tmuxSession
|
||||
|| launch.binding.id !== binding.id || launch.binding.tmuxPane !== binding.tmuxPane
|
||||
|| launch.binding.tmuxSession !== binding.tmuxSession
|
||||
|| launch.binding.launchGeneration !== binding.launchGeneration) return probe;
|
||||
try {
|
||||
const panes = await this.tmux.listPanes(binding.tmuxSession);
|
||||
if (panes.length !== 1 || panes[0]?.id !== binding.tmuxPane) return probe;
|
||||
const observation = { target: binding.tmuxPane, tmux: this.tmux,
|
||||
listProcesses: this.listProcesses, expectedToken: launch.token, selectedExecutable: launch.executable };
|
||||
const first = await observeClaudePaneProcess(observation);
|
||||
if (!first) return probe;
|
||||
// Pin the first exact process for this launch; retries cannot adopt a replacement.
|
||||
launch.fingerprint ??= first.fingerprint;
|
||||
if (first.fingerprint !== launch.fingerprint) return probe;
|
||||
const native = await observeClaudePaneProcess(observation);
|
||||
if (native?.fingerprint !== launch.fingerprint) return probe;
|
||||
const currentPanes = await this.tmux.listPanes(binding.tmuxSession);
|
||||
if (this.autoLaunches.get(binding.nodeId) !== launch || currentPanes.length !== 1
|
||||
|| currentPanes[0]?.id !== binding.tmuxPane
|
||||
|| await this.tmux.getPanePid(binding.tmuxSession) !== native.panePid) return probe;
|
||||
return assessNativeResumeProbe({ ...input, claudeAutoIdentityVerified: true });
|
||||
} catch { return probe; }
|
||||
}
|
||||
|
||||
private async verifyResumeLaunch(binding: NodeBinding): Promise<HarnessLaunchResult> {
|
||||
const tmuxSession = binding.tmuxSession!;
|
||||
const attempts = 16;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
|
||||
const paneContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand,
|
||||
paneContent,
|
||||
});
|
||||
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent);
|
||||
|
||||
if (probe.code === "no_conversation_found") {
|
||||
return {
|
||||
@@ -411,17 +443,13 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
const finalCommand = await this.tmux.getPaneCommand(tmuxSession);
|
||||
const finalContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
|
||||
const finalProbe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand: finalCommand,
|
||||
paneContent: finalContent,
|
||||
});
|
||||
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent);
|
||||
|
||||
if (finalProbe.status === "resumed") {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (finalProbe.status === "attention_required") {
|
||||
if (finalProbe.status === "attention_required" || finalProbe.code === "claude_auto_identity_required") {
|
||||
return {
|
||||
ok: false,
|
||||
error: finalProbe.detail,
|
||||
|
||||
@@ -9,14 +9,12 @@ import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
|
||||
|
||||
export type ResumeResult =
|
||||
| { ok: true; appliedLaunch?: AppliedLaunchObservation }
|
||||
// L3: `attention_required` is a non-terminal failure — Claude is alive and
|
||||
// recoverable, but the resume-selection prompt is blocking. Caller maps to
|
||||
// restoreOutcome=attention_required (do NOT auto-answer per Decision 2).
|
||||
// Non-terminal: a chooser or an inconclusive observation must preserve the
|
||||
// launch. Attention is not proof of native identity or successful continuity.
|
||||
| { ok: false; code: "attention_required"; message: string; evidence?: string }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
const CLAUDE_TYPES = new Set(["claude_name", "claude_id"]);
|
||||
const SHELL_COMMANDS = new Set(["bash", "fish", "nu", "sh", "tmux", "zsh"]);
|
||||
|
||||
interface ClaudeResumeOptions {
|
||||
claudeManagedLaunch?: ClaudeManagedLaunch;
|
||||
@@ -87,7 +85,11 @@ export class ClaudeResumeAdapter {
|
||||
return { ok: false, code: "resume_failed", message: keyResult.message };
|
||||
}
|
||||
|
||||
const result = await this.verifyResume(tmuxSessionName);
|
||||
const result = await this.verifyResume(tmuxSessionName).catch((error): ResumeResult => ({
|
||||
ok: false,
|
||||
code: "attention_required",
|
||||
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}));
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
@@ -145,11 +147,17 @@ export class ClaudeResumeAdapter {
|
||||
paneContent: finalContent,
|
||||
});
|
||||
|
||||
if (finalProbe.code === "no_conversation_found") {
|
||||
return { ok: false, code: "retry_fresh", message: "Claude resume failed: no conversation found for the requested session" };
|
||||
}
|
||||
if (finalProbe.status === "attention_required") {
|
||||
return { ok: false, code: "attention_required", message: finalProbe.detail, evidence: finalContent.split("\n").slice(-12).join("\n") };
|
||||
}
|
||||
if (finalProbe.status === "resumed") {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (finalCommand && SHELL_COMMANDS.has(finalCommand)) {
|
||||
if (finalProbe.code === "returned_to_shell") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "retry_fresh",
|
||||
@@ -159,8 +167,9 @@ export class ClaudeResumeAdapter {
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: "resume_failed",
|
||||
message: "Claude resume failed: timed out waiting for Claude to become active",
|
||||
code: "attention_required",
|
||||
message: `Claude resume could not be verified; launch retained: ${finalProbe.detail}`,
|
||||
evidence: finalContent.split("\n").slice(-12).join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Startup files and projection resources that ship with OpenRig are stored per seat as
|
||||
// absolute paths inside the install that created the seat (rigspec-instantiator resolves
|
||||
// them from import.meta.dirname, or from a rig spec that lives in the install). After an
|
||||
// upgrade that removes or moves that install, the stored paths go stale (#261). Delivery
|
||||
// consumers and restore-check re-anchor them to the RUNNING install, so seats get this
|
||||
// version's shipped content:
|
||||
// - the four built-in startup files under daemon/assets (by logical name and known path);
|
||||
// - startup files and projection resources under an install's daemon/specs (the kernel and
|
||||
// library rigs and agents), when both their root and their file lie in that same specs root.
|
||||
// A packaged install (@openrig/cli/daemon/...) is re-anchored even while it still exists, so
|
||||
// seats never keep an old version's shipped content. A dev checkout (packages/daemon/...) is
|
||||
// re-anchored only when the stored file is missing: its files may be newer than, or absent
|
||||
// from, the running install. Custom, user, plugin and other external paths are never rewritten.
|
||||
import { existsSync } from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Logical name -> path relative to the daemon assets root, as rigspec-instantiator produces them. */
|
||||
const BUILTIN_STARTUP_FILES: ReadonlyMap<string, string> = new Map([
|
||||
["CULTURE-default.md", "guidance/CULTURE-default.md"],
|
||||
["openrig-start.md", "guidance/openrig-start.md"],
|
||||
["openrig-onboarding-01.md", "onboarding/01-world-and-purpose.md"],
|
||||
["openrig-onboarding-02.md", "onboarding/02-self-and-competent-action.md"],
|
||||
]);
|
||||
|
||||
type Exists = (path: string) => boolean;
|
||||
type InstallLayout = "packaged" | "dev-checkout";
|
||||
|
||||
/** The running daemon's assets root (packages/daemon/assets, or <cli>/daemon/assets when packaged). */
|
||||
export function runningBuiltinAssetsRoot(): string {
|
||||
return nodePath.resolve(import.meta.dirname, "../../assets");
|
||||
}
|
||||
|
||||
/** The running daemon's shipped specs root (packages/daemon/specs, or <cli>/daemon/specs when packaged). */
|
||||
export function runningShippedSpecsRoot(): string {
|
||||
return nodePath.resolve(import.meta.dirname, "../../specs");
|
||||
}
|
||||
|
||||
/** The layout of the OpenRig install whose daemon/<dir> root is `parts[0..i+1]`, if recognized. */
|
||||
function layoutAt(parts: string[], i: number): InstallLayout | null {
|
||||
if (i >= 2 && parts[i - 1] === "cli" && parts[i - 2] === "@openrig") return "packaged";
|
||||
if (i >= 1 && parts[i - 1] === "packages") return "dev-checkout";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The recognized OpenRig install's daemon/specs root containing `p`, with its layout. */
|
||||
function shippedSpecsRootOf(p: string): { root: string; layout: InstallLayout } | null {
|
||||
const parts = nodePath.resolve(p).split(nodePath.sep);
|
||||
for (let i = parts.length - 2; i >= 1; i--) {
|
||||
if (parts[i] !== "daemon" || parts[i + 1] !== "specs") continue;
|
||||
const layout = layoutAt(parts, i);
|
||||
if (layout) return { root: parts.slice(0, i + 2).join(nodePath.sep), layout };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A stored file from a dev checkout is followed only when it is gone; a packaged one always. */
|
||||
function shouldReanchor(layout: InstallLayout, storedFile: string, exists: Exists): boolean {
|
||||
return layout === "packaged" || !exists(storedFile);
|
||||
}
|
||||
|
||||
/** Map a (root, file) pair stored under one install's daemon/specs onto the running specs root,
|
||||
* preserving both relative paths. Null when the root is not in a recognized install's specs,
|
||||
* the file lies outside that same specs root, or a dev-checkout file is still present. */
|
||||
function mapUnderShippedSpecs(root: string, file: string, specsRoot: string, exists: Exists): { root: string; file: string } | null {
|
||||
const stored = shippedSpecsRootOf(root);
|
||||
if (!stored) return null;
|
||||
const resolvedFile = nodePath.resolve(file);
|
||||
if (!resolvedFile.startsWith(stored.root + nodePath.sep)) return null;
|
||||
if (!shouldReanchor(stored.layout, file, exists)) return null;
|
||||
return {
|
||||
root: nodePath.join(specsRoot, nodePath.relative(stored.root, nodePath.resolve(root))),
|
||||
file: nodePath.join(specsRoot, nodePath.relative(stored.root, resolvedFile)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor one stored startup file to the running install when it ships with OpenRig:
|
||||
* - a built-in: its logical name is one of the four built-ins, its stored absolutePath is
|
||||
* exactly <ownerRoot>/<known relative path>, and ownerRoot is a recognized install's
|
||||
* daemon/assets directory; or
|
||||
* - a shipped-spec file: its ownerRoot and absolutePath both lie under the same recognized
|
||||
* install's daemon/specs (for example the kernel rig culture and agent role/startup files).
|
||||
* Dev-checkout files are re-anchored only when missing. Anything else (custom rig/agent files,
|
||||
* user content with a matching basename) is returned unchanged. Required, applicability,
|
||||
* delivery and every other field are preserved.
|
||||
*/
|
||||
export function reanchorBuiltinStartupFile<T extends { path: string; absolutePath: string; ownerRoot: string }>(
|
||||
file: T,
|
||||
assetsRoot: string = runningBuiltinAssetsRoot(),
|
||||
specsRoot: string = runningShippedSpecsRoot(),
|
||||
exists: Exists = existsSync,
|
||||
): T {
|
||||
// Persisted contexts from older versions may lack ownerRoot/path; leave them exactly as stored.
|
||||
if (typeof file.ownerRoot !== "string" || typeof file.absolutePath !== "string") return file;
|
||||
const relative = typeof file.path === "string" ? BUILTIN_STARTUP_FILES.get(file.path) : undefined;
|
||||
if (relative) {
|
||||
const storedRoot = nodePath.resolve(file.ownerRoot);
|
||||
const parts = storedRoot.split(nodePath.sep);
|
||||
const i = parts.length - 2;
|
||||
const layout = parts[i] === "daemon" && parts[i + 1] === "assets" ? layoutAt(parts, i) : null;
|
||||
if (layout && nodePath.resolve(file.absolutePath) === nodePath.join(storedRoot, relative)) {
|
||||
return shouldReanchor(layout, file.absolutePath, exists)
|
||||
? { ...file, absolutePath: nodePath.join(assetsRoot, relative), ownerRoot: assetsRoot }
|
||||
: file;
|
||||
}
|
||||
}
|
||||
const mapped = mapUnderShippedSpecs(file.ownerRoot, file.absolutePath, specsRoot, exists);
|
||||
return mapped ? { ...file, ownerRoot: mapped.root, absolutePath: mapped.file } : file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor one stored projection entry to the running install when it is a shipped-spec
|
||||
* resource: both its sourcePath and its resource absolutePath lie under the same recognized
|
||||
* OpenRig install's daemon/specs root (dev-checkout resources only when missing). A resource
|
||||
* stored elsewhere (a plugin under ~/.openrig/plugins, user specs, custom paths) is returned
|
||||
* unchanged even when its sourcePath is a shipped spec. Identifiers, category, target and
|
||||
* merge behavior are preserved.
|
||||
*/
|
||||
export function reanchorShippedProjectionEntry<T extends { sourcePath: string; absolutePath: string }>(
|
||||
entry: T,
|
||||
specsRoot: string = runningShippedSpecsRoot(),
|
||||
exists: Exists = existsSync,
|
||||
): T {
|
||||
// Persisted entries from older versions may lack sourcePath; leave them exactly as stored.
|
||||
if (typeof entry.sourcePath !== "string" || typeof entry.absolutePath !== "string") return entry;
|
||||
const mapped = mapUnderShippedSpecs(entry.sourcePath, entry.absolutePath, specsRoot, exists);
|
||||
return mapped ? { ...entry, sourcePath: mapped.root, absolutePath: mapped.file } : entry;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import nodePath from "node:path";
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface SkillsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
/** Byte-for-byte copy; routed files are never decoded. */
|
||||
copyFile: (src: string, dest: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -125,8 +125,7 @@ export function routeSkills(input: RouteSkillsInput, fs: SkillsRouterFsOps): Rou
|
||||
continue;
|
||||
}
|
||||
fs.mkdirp(nodePath.dirname(targetAbs));
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
fs.copyFile(sourceAbs, targetAbs);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
|
||||
@@ -31,8 +31,8 @@ import nodePath from "node:path";
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface WorkflowSpecsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
/** Byte-for-byte copy; routed files are never decoded. */
|
||||
copyFile: (src: string, dest: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -195,8 +195,7 @@ export function routeWorkflowSpecs(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
fs.copyFile(sourceAbs, targetAbs);
|
||||
routedBasenames.add(basename);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
|
||||
@@ -72,7 +72,7 @@ export class ClaudeManagedLaunch {
|
||||
}
|
||||
|
||||
async prepare(request: ClaudeLaunchTarget, mode: string): Promise<{
|
||||
assertCurrent: () => void; command: (args: readonly string[]) => string; configDir: string;
|
||||
assertCurrent: () => void; command: (args: readonly string[]) => string; configDir: string; executable: string;
|
||||
}> {
|
||||
const target = Object.freeze({ ...request });
|
||||
const before = this.target(target.nodeId);
|
||||
@@ -100,7 +100,7 @@ export class ClaudeManagedLaunch {
|
||||
}
|
||||
};
|
||||
const help = await new Promise<string>((resolve, reject) => {
|
||||
execFile(context.executable, ["--help"], { cwd, env: context.env, encoding: "utf8", timeout: 1000, maxBuffer: 1024 * 1024 },
|
||||
execFile(context.executable, ["--help"], { cwd, env: context.env, encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 },
|
||||
(error, stdout) => error ? reject(new Error("Claude managed capability query failed; no fallback was selected.")) : resolve(stdout));
|
||||
});
|
||||
assertCurrent();
|
||||
@@ -111,7 +111,7 @@ export class ClaudeManagedLaunch {
|
||||
...(generation ? { OPENRIG_OCCUPANT_GENERATION: generation } : {}) };
|
||||
const assignments = Object.entries({ ...context.env, ...identity }).map(([key, value]) => shellQuote(`${key}=${value}`));
|
||||
const forwarded = inherited.filter(key => !(key in identity)).map(key => `"${key}=\${${key}-}"`);
|
||||
return Object.freeze({ assertCurrent, configDir: context.configDir, command: (args: readonly string[]) => {
|
||||
return Object.freeze({ assertCurrent, configDir: context.configDir, executable: context.executable, command: (args: readonly string[]) => {
|
||||
assertCurrent();
|
||||
return `cd ${shellQuote(cwd)} && /usr/bin/env -i ${[...assignments, ...forwarded, shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}`;
|
||||
} });
|
||||
|
||||
@@ -110,7 +110,7 @@ export class InboundRouter {
|
||||
constructor(private readonly deps: InboundDeps) {}
|
||||
|
||||
private summaryOf(ev: SlackEvent, transfer?: InboundFileResult | null, correlationQitemId?: string): { summary: string; body: string } {
|
||||
const text = String(ev.text ?? "").slice(0, 1800);
|
||||
const text = String(ev.text ?? "");
|
||||
const meta = `slack channel=${ev.channel} user=${ev.user} ts=${ev.ts}`;
|
||||
// OPR.0.5.6.2 — attachments ride the row BODY by LOCAL path (Slack owns
|
||||
// nothing; the media file is OUR copy). Failures are per-file and named:
|
||||
|
||||
@@ -73,6 +73,12 @@ export function startSocketInbound(appToken: string, router: InboundRouter, deps
|
||||
}
|
||||
};
|
||||
|
||||
const retryDeadLetters = (): void => {
|
||||
void router.retryDeadLetters().catch((error) => {
|
||||
log(`dead-letter retry failed: ${(error as Error).message}`);
|
||||
});
|
||||
};
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const connect = async (): Promise<void> => {
|
||||
if (stopped) return resolve();
|
||||
@@ -102,10 +108,10 @@ export function startSocketInbound(appToken: string, router: InboundRouter, deps
|
||||
status.state = "connected";
|
||||
status.connectedAt = stamp();
|
||||
receipt({ generation: connects, status: "connected" });
|
||||
void router.retryDeadLetters(); // drain on connect (cold-init)…
|
||||
retryDeadLetters(); // drain on connect (cold-init)…
|
||||
// …AND periodically WHILE connected (B1: recovery after a queue outage
|
||||
// must not wait for the next Slack reconnect). Cleared on close.
|
||||
retryTimer = setInterval(() => void router.retryDeadLetters(), retryIntervalMs);
|
||||
retryTimer = setInterval(retryDeadLetters, retryIntervalMs);
|
||||
if (typeof (retryTimer as unknown as { unref?: () => void }).unref === "function") {
|
||||
(retryTimer as unknown as { unref: () => void }).unref();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -24,10 +25,29 @@ function executableName(token: string): string {
|
||||
return (token.split("/").pop() ?? token).toLowerCase().replace(/\.exe$/, "");
|
||||
}
|
||||
|
||||
// The native installer resolves `claude` to this versioned path. A bare version
|
||||
// number is never executable identity. A launch receipt takes precedence over
|
||||
// layout recognition, so a later PATH update cannot replace that launch's binary.
|
||||
function claudeExecutable(token: string, selectedExecutable?: string): boolean {
|
||||
// An observed path must match the frozen launch path, even when its basename
|
||||
// is claude. A bare process title carries no path and retains legacy token proof.
|
||||
if (selectedExecutable && token.includes("/")) return token === selectedExecutable;
|
||||
if (executableName(token) === "claude") return true; // includes native process-title spelling
|
||||
if (selectedExecutable) return token === selectedExecutable;
|
||||
return token.startsWith("/") && !token.split("/").some(part => part === "." || part === "..")
|
||||
&& /\/\.local\/share\/claude\/versions\/\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(token);
|
||||
}
|
||||
|
||||
function claudeProcess(row: NativeProcessRow, selectedExecutable?: string): boolean {
|
||||
const argv0 = tokens(row.command)[0] ?? "";
|
||||
return claudeExecutable(argv0, selectedExecutable)
|
||||
&& executableName(row.executableName ?? "") === executableName(argv0);
|
||||
}
|
||||
|
||||
function commandUsesExpectedToken(command: string, runtime: NativeRuntime, expectedToken: string): boolean {
|
||||
const argv = tokens(command);
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
const executableIndex = argv.findIndex((token) => executableName(token) === executable);
|
||||
const executableIndex = argv.findIndex((token) => runtime === "claude-code" ? claudeExecutable(token) : executableName(token) === executable);
|
||||
if (executableIndex < 0) return false;
|
||||
const args = argv.slice(executableIndex + 1);
|
||||
if (runtime === "claude-code") {
|
||||
@@ -141,15 +161,16 @@ export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProce
|
||||
type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
|
||||
export type CodexProcessObservation = NativeProcessObservation;
|
||||
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex"): NativeProcessObservation | null {
|
||||
function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, selectedExecutable?: string): NativeProcessObservation[] {
|
||||
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
||||
const root = byPid.get(panePid);
|
||||
if (byPid.size !== rows.length || !root?.startedAt || !root.tpgid || root.tpgid <= 0) return null;
|
||||
if (byPid.size !== rows.length || !root?.startedAt || !root.tpgid || root.tpgid <= 0) return [];
|
||||
const matches: { process: NativeProcessRow; chain: NativeProcessRow[] }[] = [];
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
for (const row of rows) {
|
||||
const osExecutable = runtime === "claude-code" ? executableName(row.executableName ?? "") : row.executableName;
|
||||
if (osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable
|
||||
if ((runtime === "claude-code" ? !claudeProcess(row, selectedExecutable)
|
||||
: osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable)
|
||||
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
const chain: NativeProcessRow[] = [];
|
||||
const visited = new Set<number>();
|
||||
@@ -161,8 +182,15 @@ function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expected
|
||||
current = byPid.get(current.ppid);
|
||||
}
|
||||
}
|
||||
return matches.map(({ process, chain }) => ({ panePid, process,
|
||||
fingerprint: JSON.stringify(chain.map(row => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command])) }));
|
||||
}
|
||||
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string): NativeProcessObservation | null {
|
||||
const matches = nativeProcessCandidates(rows, panePid, runtime, selectedExecutable);
|
||||
if (matches.length !== 1) return null;
|
||||
const { process, chain } = matches[0]!;
|
||||
const observation = matches[0]!;
|
||||
const { process } = observation;
|
||||
if (runtime === "claude-code") {
|
||||
if (!expectedToken || claudeSessionToken(tokens(process.command).slice(1)) !== expectedToken) return null;
|
||||
} else {
|
||||
@@ -171,7 +199,7 @@ function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expected
|
||||
if ((requireResume || (expectedToken !== undefined && resumeToken !== undefined))
|
||||
&& (!expectedToken || resumeToken !== expectedToken)) return null;
|
||||
}
|
||||
return { panePid, process, fingerprint: JSON.stringify(chain.map((row) => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command])) };
|
||||
return observation;
|
||||
}
|
||||
|
||||
async function observeNativePaneProcess(input: {
|
||||
@@ -180,12 +208,14 @@ async function observeNativePaneProcess(input: {
|
||||
listProcesses?: NativeProcessLister;
|
||||
expectedToken?: string | null;
|
||||
requireResume?: boolean;
|
||||
/** Canonical executable frozen by the managed launch, never re-resolved at observation time. */
|
||||
selectedExecutable?: string;
|
||||
}, runtime: NativeRuntime): Promise<NativeProcessObservation | null> {
|
||||
try {
|
||||
const pid = await input.tmux.getPanePid(input.target);
|
||||
if (!pid) return null;
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime);
|
||||
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -200,9 +230,67 @@ export async function verifyCodexPaneProcess(input: Parameters<typeof observeCod
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
|
||||
/** Single observation for callers that pin identity across their own launch checks. */
|
||||
export async function observeClaudePaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<NativeProcessObservation | null> {
|
||||
return observeNativePaneProcess(input, "claude-code");
|
||||
}
|
||||
|
||||
export async function verifyClaudePaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<NativeProcessObservation | null> {
|
||||
const first = await observeNativePaneProcess(input, "claude-code");
|
||||
if (!first) return null;
|
||||
const second = await observeNativePaneProcess(input, "claude-code");
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
|
||||
export interface ClaudeDeliveryObservation {
|
||||
state: "verified" | "unknown" | "idle_shell" | "conflict";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** Ordinary delivery's uncertainty policy is separate from readiness/identity proof. */
|
||||
export async function observeClaudeDelivery(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<ClaudeDeliveryObservation> {
|
||||
const unknown = { state: "unknown" as const, detail: "Claude runtime identity could not be established" };
|
||||
const sample = async (): Promise<ClaudeDeliveryObservation & { fingerprint?: string }> => {
|
||||
try {
|
||||
const pid = await input.tmux.getPanePid(input.target);
|
||||
if (!pid) return unknown;
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
const candidates = nativeProcessCandidates(rows, pid, "claude-code", input.selectedExecutable);
|
||||
if (candidates.length > 1) return { state: "conflict", detail: "Multiple Claude processes occupy the bound foreground" };
|
||||
const native = candidates[0];
|
||||
if (native) {
|
||||
const token = claudeSessionToken(tokens(native.process.command).slice(1));
|
||||
const fingerprint = native.fingerprint;
|
||||
if (!token || !input.expectedToken) return { ...unknown, fingerprint };
|
||||
return token === input.expectedToken
|
||||
? { state: "verified", detail: "Expected Claude conversation in the bound foreground", fingerprint }
|
||||
: { state: "conflict", detail: "The bound foreground names a different Claude conversation", fingerprint };
|
||||
}
|
||||
const other = selectNativeProcess(rows, pid);
|
||||
if (other) return { state: "conflict", detail: "A different native runtime occupies the bound foreground", fingerprint: other.fingerprint };
|
||||
const root = rows.find(row => row.pid === pid);
|
||||
// A wrapper's label is not an idle shell. Positive shell proof requires
|
||||
// the pane shell itself to own the foreground, with no receiving child.
|
||||
// A background child/helper in another group does not receive terminal input.
|
||||
if (new Set(rows.map(row => row.pid)).size === rows.length && root?.startedAt
|
||||
&& root.pgid === pid && root.tpgid === pid
|
||||
&& isShellForeground(executableName(root.executableName ?? ""))
|
||||
&& isShellForeground(executableName(tokens(root.command)[0]?.replace(/^-/, "") ?? ""))
|
||||
&& !rows.some(row => row.pid !== pid && row.pgid === root.tpgid)) {
|
||||
return { state: "idle_shell", detail: "The bound foreground is an idle shell with no receiving child", fingerprint: JSON.stringify(root) };
|
||||
}
|
||||
return unknown;
|
||||
} catch { return unknown; }
|
||||
};
|
||||
const first = await sample();
|
||||
const second = await sample();
|
||||
if (first.state === "conflict") return first;
|
||||
if (second.state === "conflict") return second;
|
||||
// An unavailable sample cannot erase a positive idle-shell refusal.
|
||||
if (first.state === "idle_shell" && second.state === "unknown") return first;
|
||||
if (second.state === "idle_shell" && first.state === "unknown") return second;
|
||||
if (first.fingerprint && second.fingerprint && first.fingerprint !== second.fingerprint) {
|
||||
return { state: "conflict", detail: "The observed foreground process changed during delivery verification" };
|
||||
}
|
||||
return first.state === second.state && first.fingerprint && first.fingerprint === second.fingerprint ? second : unknown;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface NativeResumeProbeInput {
|
||||
runtime: string | null;
|
||||
paneCommand: string | null;
|
||||
paneContent: string | null;
|
||||
/** Only the managed adapter supplies this after exact, stable native-process proof. */
|
||||
claudeAutoIdentityVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface NativeResumeProbeResult {
|
||||
@@ -131,6 +133,12 @@ export function assessNativeResumeProbe(
|
||||
detail: "Claude is the active foreground process in the probe pane.",
|
||||
};
|
||||
}
|
||||
if (/(^|\n)\s*❯/.test(paneContent)
|
||||
&& /^[ \t]*⏵⏵ auto mode on \(shift\+tab to cycle\)(?:[ \t]+·[^\r\n]*)?[ \t]*$/m.test(paneContent)) {
|
||||
return input.claudeAutoIdentityVerified
|
||||
? { status: "resumed", code: "active_runtime", detail: "Claude auto-mode TUI and the exact managed native identity were verified." }
|
||||
: { status: "inconclusive", code: "claude_auto_identity_required", detail: "Auto-mode screen text requires proof of the launched Claude identity." };
|
||||
}
|
||||
if (SHELL_COMMANDS.has(paneCommand)) {
|
||||
return {
|
||||
status: "failed",
|
||||
|
||||
@@ -7,8 +7,10 @@ import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgent
|
||||
import type { RigSpec, StartupBlock } from "./types.js";
|
||||
|
||||
export interface PodAssemblerFsOps extends AgentResolverFsOps {
|
||||
/** Raw bytes, for files the bundle copies verbatim (agent packages, culture, docs, startup files). */
|
||||
readFileBuffer(path: string): Uint8Array;
|
||||
mkdirp(path: string): void;
|
||||
writeFile(path: string, content: string): void;
|
||||
writeFile(path: string, content: string | Uint8Array): void;
|
||||
copyDir(src: string, dest: string): void;
|
||||
listFiles(dirPath: string): string[];
|
||||
}
|
||||
@@ -214,7 +216,7 @@ export class PodBundleAssembler {
|
||||
throw new Error(`Path traversal detected: "${relPath}" escapes rig root`);
|
||||
}
|
||||
if (!this.fs.exists(absPath)) return; // optional files may not exist
|
||||
const content = this.fs.readFile(absPath);
|
||||
const content = this.fs.readFileBuffer(absPath);
|
||||
assertShippableSubstance([{ path: relPath, bytes: content }]);
|
||||
this.fs.mkdirp(nodePath.dirname(nodePath.join(outputDir, relPath)));
|
||||
this.fs.writeFile(nodePath.join(outputDir, relPath), content);
|
||||
@@ -232,7 +234,7 @@ export class PodBundleAssembler {
|
||||
const files = this.fs.listFiles(srcDir);
|
||||
const sources = files.map((file) => ({
|
||||
file,
|
||||
content: this.fs.readFile(nodePath.join(srcDir, file)),
|
||||
content: this.fs.readFileBuffer(nodePath.join(srcDir, file)),
|
||||
}));
|
||||
assertShippableSubstance(sources.map(({ file, content }) => ({
|
||||
path: nodePath.join(relPrefix, file),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, accessSync, constants } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import { getCompatibleOpenRigPath } from "../openrig-compat.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -112,12 +113,14 @@ export interface StartupContextResolvedFile {
|
||||
required: boolean;
|
||||
path?: string | null;
|
||||
deliveryHint?: string | null;
|
||||
ownerRoot?: string | null;
|
||||
}
|
||||
|
||||
export interface StartupContextProjectionEntry {
|
||||
absolutePath: string;
|
||||
effectiveId?: string | null;
|
||||
category?: string | null;
|
||||
sourcePath?: string | null;
|
||||
}
|
||||
|
||||
export type StartupContextProbeResult =
|
||||
@@ -851,7 +854,21 @@ export class RestoreCheckService {
|
||||
break;
|
||||
}
|
||||
|
||||
const startupContext = probe;
|
||||
// #261: inspect (and report) the paths replay will actually use — recognized built-in startup
|
||||
// files and shipped-spec projection resources follow the running install.
|
||||
const startupContext = {
|
||||
...probe,
|
||||
resolvedStartupFiles: probe.resolvedStartupFiles.map((file) => (
|
||||
typeof file.path === "string" && typeof file.ownerRoot === "string"
|
||||
? reanchorBuiltinStartupFile({ ...file, path: file.path, ownerRoot: file.ownerRoot }, undefined, undefined, this.deps.exists)
|
||||
: file
|
||||
)),
|
||||
projectionEntries: probe.projectionEntries.map((entry) => (
|
||||
typeof entry.sourcePath === "string"
|
||||
? reanchorShippedProjectionEntry({ ...entry, sourcePath: entry.sourcePath }, undefined, this.deps.exists)
|
||||
: entry
|
||||
)),
|
||||
};
|
||||
const missingRequired = startupContext.resolvedStartupFiles.filter((file) => file.required && !this.deps.exists(file.absolutePath));
|
||||
const missingOptional = startupContext.resolvedStartupFiles.filter((file) => !file.required && !this.deps.exists(file.absolutePath));
|
||||
const missingProjectionEntries = startupContext.projectionEntries.filter((entry) => !this.deps.exists(entry.absolutePath));
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { CodexResumeAdapter } from "../adapters/codex-resume.js";
|
||||
import type { PiResumeAdapter } from "../adapters/pi-resume.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { assessNativeResumeProbe } from "./native-resume-probe.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import type {
|
||||
RestoreOutcome,
|
||||
RestoreRigResult,
|
||||
@@ -695,7 +696,9 @@ export class RestoreOrchestrator {
|
||||
else consumesReplay = true;
|
||||
}
|
||||
|
||||
for (const file of consumesReplay ? startupCtx.resolvedStartupFiles ?? [] : []) {
|
||||
for (const storedFile of consumesReplay ? startupCtx.resolvedStartupFiles ?? [] : []) {
|
||||
// Validate the file replay will actually deliver (#261: built-ins follow the running install).
|
||||
const file = reanchorBuiltinStartupFile(storedFile, undefined, undefined, exists);
|
||||
if (!file.required) {
|
||||
if (this.pathLike(file.absolutePath) && !exists(file.absolutePath)) {
|
||||
warnings.push(`Restore pre-validation: optional startup file missing for ${node.logicalId}: ${file.absolutePath}`);
|
||||
@@ -737,7 +740,8 @@ export class RestoreOrchestrator {
|
||||
// gate from blocking the attempt entirely. Missing REQUIRED startup
|
||||
// files and genuinely-fatal blockers (malformed snapshot, missing nodes)
|
||||
// stay critical above.
|
||||
for (const entry of startupCtx.projectionEntries ?? []) {
|
||||
for (const storedEntry of startupCtx.projectionEntries ?? []) {
|
||||
const entry = reanchorShippedProjectionEntry(storedEntry, undefined, exists);
|
||||
if (this.pathLike(entry.sourcePath) && !exists(entry.sourcePath)) {
|
||||
warnings.push(`projection_drift: source root missing for ${node.logicalId}: ${entry.sourcePath} (projection will be skipped at startup; session continuity is unaffected)`);
|
||||
}
|
||||
@@ -1303,8 +1307,9 @@ export class RestoreOrchestrator {
|
||||
if (adapter) {
|
||||
// Prefilter: check which files/entries still exist
|
||||
const existsFn = opts.fsOps?.exists ?? (() => true);
|
||||
const sourceEntries = replayContained ? [] : startupCtx.projectionEntries;
|
||||
const sourceFiles = replayContained ? [] : startupCtx.resolvedStartupFiles;
|
||||
const sourceEntries = replayContained ? [] : startupCtx.projectionEntries.map((e) => reanchorShippedProjectionEntry(e, undefined, existsFn));
|
||||
// #261: recognized built-in startup files follow the running install.
|
||||
const sourceFiles = replayContained ? [] : startupCtx.resolvedStartupFiles.map((f) => reanchorBuiltinStartupFile(f, undefined, undefined, existsFn));
|
||||
const sourceActions = replayContained ? [] : startupCtx.startupActions;
|
||||
const filteredEntries = sourceEntries.filter((e) => {
|
||||
if (!existsFn(e.absolutePath)) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { RuntimeAdapter, ResolvedStartupFile } from "./runtime-adapter.js";
|
||||
import type { ProjectionEntry, ProjectionPlan } from "./projection-planner.js";
|
||||
import type { StartupAction } from "./types.js";
|
||||
import { resolveStartupProof } from "./startup-resolver.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import type { OccupantInvalidator } from "./occupant-invalidator.js";
|
||||
import { rebindAndVerifyPaneIdentity } from "./seat-attention-reconciler.js";
|
||||
import { observeSolePane } from "./pane-binding-observation.js";
|
||||
@@ -550,6 +551,11 @@ export class SeatLifecycleService {
|
||||
input.operator,
|
||||
);
|
||||
if (!stopped.ok) return stopped;
|
||||
// Stopping the server's last session ends tmux's server, and every probe
|
||||
// below would then be transport_unavailable, never absence. Restore an
|
||||
// empty server (no session is invented; a no-op while the server is up)
|
||||
// so they get a positive answer. The classified probes still decide.
|
||||
await this.tmuxAdapter.startServer();
|
||||
}
|
||||
|
||||
// Reuse clean's exhaustive, positive-absence gate for stale/history rows.
|
||||
@@ -842,7 +848,8 @@ export class SeatLifecycleService {
|
||||
// S04 owns the live ambient skill set. Replaying the older catalog
|
||||
// selection here could reinstall a skill that work-install removed.
|
||||
if (raw["category"] === "skill") continue;
|
||||
entries.push({
|
||||
// #261: shipped-spec resources follow the running install.
|
||||
entries.push(reanchorShippedProjectionEntry({
|
||||
category: raw["category"],
|
||||
effectiveId: raw["effectiveId"],
|
||||
sourceSpec: raw["sourceSpec"],
|
||||
@@ -854,7 +861,7 @@ export class SeatLifecycleService {
|
||||
...(typeof raw["mergeStrategy"] === "string" ? { mergeStrategy: raw["mergeStrategy"] as ProjectionEntry["mergeStrategy"] } : {}),
|
||||
...(typeof raw["target"] === "string" ? { target: raw["target"] } : {}),
|
||||
...(typeof raw["pluginType"] === "string" ? { pluginType: raw["pluginType"] as ProjectionEntry["pluginType"] } : {}),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
const resolvedStartupFiles: ResolvedStartupFile[] = [];
|
||||
@@ -867,7 +874,8 @@ export class SeatLifecycleService {
|
||||
|| !isOptionalOneOf(raw["kind"], ["file"] as const)) {
|
||||
return this.malformedStartupContext(nodeId, "resolved_files_json contains an invalid entry");
|
||||
}
|
||||
resolvedStartupFiles.push({
|
||||
// #261: recognized built-in startup files follow the running install.
|
||||
resolvedStartupFiles.push(reanchorBuiltinStartupFile({
|
||||
path: raw["path"],
|
||||
absolutePath: raw["absolutePath"],
|
||||
ownerRoot: raw["ownerRoot"],
|
||||
@@ -875,7 +883,7 @@ export class SeatLifecycleService {
|
||||
required: raw["required"],
|
||||
appliesOn: raw["appliesOn"],
|
||||
...(raw["kind"] === "file" ? { kind: "file" as const } : {}),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
const startupActions: StartupAction[] = [];
|
||||
|
||||
@@ -11,7 +11,7 @@ import { wrapPaneEnvelope, appendDeliveredSegment, type EnvelopeScope } from "..
|
||||
import { getSelfHostId } from "./hosts/fanout-contract.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { verifyClaudePaneProcess, verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { observeClaudeDelivery, verifyCodexPaneProcess, type ClaudeDeliveryObservation, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
|
||||
|
||||
@@ -955,6 +955,22 @@ export class SessionTransport {
|
||||
let preVerifyContent: string | null = null;
|
||||
const sessionMeta = this.getSessionMeta(sessionName);
|
||||
const runtime = sessionMeta.runtime;
|
||||
let runtimeAdvisory: string | undefined;
|
||||
const bindingChanged = () => JSON.stringify(this.getSessionMeta(sessionName)) !== JSON.stringify(sessionMeta);
|
||||
const changedRecipient = (sent = false): SendResult => ({ ok: false, sessionName, sent, reason: "target_runtime_conflict",
|
||||
error: sent ? "Recipient binding changed after paste; Enter was not sent." : "Recipient binding changed; no text was sent." });
|
||||
const checkClaudeTarget = async (): Promise<SendResult | null> => {
|
||||
if (runtime !== "claude-code") return null;
|
||||
if (bindingChanged()) return changedRecipient();
|
||||
const observation = await this.claudeDeliveryObservation(sessionName, sessionMeta.pane, sessionMeta.resumeToken);
|
||||
if (bindingChanged()) return changedRecipient();
|
||||
if (observation.state === "idle_shell" || observation.state === "conflict") {
|
||||
return { ok: false, sessionName, sent: false, reason: observation.state === "idle_shell" ? "target_runtime_not_running" : "target_runtime_conflict",
|
||||
error: `Refused: ${observation.detail}. No text was sent.` };
|
||||
}
|
||||
if (observation.state === "unknown") runtimeAdvisory = `runtime: ${observation.detail}; delivery proceeds without verified native identity.`;
|
||||
return null;
|
||||
};
|
||||
// S01/S02 P2 observation context, frozen at attempt entry before any await.
|
||||
const observed = this.captureObserver ? {
|
||||
attemptId: randomUUID(),
|
||||
@@ -964,6 +980,7 @@ export class SessionTransport {
|
||||
sentHash: null as string | null,
|
||||
} : null;
|
||||
const observe = (result: SendResult): SendResult => {
|
||||
if (result.ok && runtimeAdvisory) result = { ...result, warning: [runtimeAdvisory, result.warning].filter(Boolean).join(" ") };
|
||||
if (observed && this.captureObserver) {
|
||||
safeRecord(this.captureObserver, {
|
||||
seam: "send_verify",
|
||||
@@ -993,9 +1010,9 @@ export class SessionTransport {
|
||||
}
|
||||
|
||||
// #142 — a shell label may be an idle shell or a managed launch wrapper.
|
||||
// Only positive native process proof clears the refusal, but missing proof
|
||||
// does not establish that the runtime stopped. Terminal/unreadable behavior is unchanged.
|
||||
const unverifiedShell = runtime && runtime !== "terminal"
|
||||
// Non-Claude runtimes retain their existing proof requirement. Claude ordinary
|
||||
// delivery applies its distinct uncertainty policy at the input boundary below.
|
||||
const unverifiedShell = runtime && runtime !== "terminal" && runtime !== "claude-code"
|
||||
? await this.unverifiedShellForeground(sessionName, runtime, sessionMeta.pane, sessionMeta.resumeToken) : null;
|
||||
if (unverifiedShell) {
|
||||
return observe({
|
||||
@@ -1162,6 +1179,8 @@ export class SessionTransport {
|
||||
error: `submitOnly refused: the pane of '${sessionName}' does not show the expected staged text — pressing Enter here could drive something else entirely. Nothing was submitted.`,
|
||||
};
|
||||
}
|
||||
const targetFailure = await checkClaudeTarget();
|
||||
if (targetFailure) return targetFailure;
|
||||
const submitResult = await this.runStage(
|
||||
"session_transport.submit",
|
||||
() => this.tmuxAdapter.sendKeys(sessionName, ["C-m"]),
|
||||
@@ -1170,7 +1189,7 @@ export class SessionTransport {
|
||||
if (!submitResult.ok) {
|
||||
return { ok: false, sessionName, reason: "submit_failed", outcome: "failed", error: `submitOnly: Enter did not land on '${sessionName}': ${submitResult.message}` };
|
||||
}
|
||||
return { ok: true, sessionName, outcome: "rendered-unconfirmed", submitOnly: true };
|
||||
return observe({ ok: true, sessionName, outcome: "rendered-unconfirmed", submitOnly: true });
|
||||
}
|
||||
|
||||
if (waitForIdleMs !== undefined) {
|
||||
@@ -1304,6 +1323,10 @@ export class SessionTransport {
|
||||
text = appendDeliveredSegment(text, this.now().getTime() - Date.parse(opts.stampISO));
|
||||
}
|
||||
|
||||
// Recheck the selected recipient after readiness/capture awaits, at the input boundary.
|
||||
const targetFailure = await checkClaudeTarget();
|
||||
if (targetFailure) return observe(targetFailure);
|
||||
|
||||
// 3. Send text (paste)
|
||||
if (observed) observed.sentHash = hashSentText(text);
|
||||
const textResult = await this.runStage(
|
||||
@@ -1325,6 +1348,8 @@ export class SessionTransport {
|
||||
// 4. Wait 200ms (spike-proven delay)
|
||||
await this.sleep(200);
|
||||
|
||||
if (runtime === "claude-code" && bindingChanged()) return observe(changedRecipient(true));
|
||||
|
||||
// 5. Submit (C-m)
|
||||
const submitResult = await this.runStage(
|
||||
"session_transport.submit",
|
||||
@@ -1441,6 +1466,27 @@ export class SessionTransport {
|
||||
}
|
||||
}
|
||||
|
||||
private async claudeDeliveryObservation(sessionName: string, pane: string | null, resumeToken: string | null): Promise<ClaudeDeliveryObservation> {
|
||||
const unknown = { state: "unknown" as const, detail: "Claude runtime observation or older launch binding is unavailable" };
|
||||
try {
|
||||
const panes = await this.tmuxAdapter.listPanes(sessionName);
|
||||
if (panes.length > 1 || (pane && panes.length === 1 && panes[0]!.id !== pane)) {
|
||||
return { state: "conflict", detail: "The session does not have the single expected bound pane" };
|
||||
}
|
||||
if (!pane || panes.length === 0) return unknown;
|
||||
const [sessionPid, panePid] = await Promise.all([this.tmuxAdapter.getPanePid(sessionName), this.tmuxAdapter.getPanePid(pane)]);
|
||||
if (sessionPid && panePid && sessionPid !== panePid) return { state: "conflict", detail: "The session and bound pane name different processes" };
|
||||
const observation = await observeClaudeDelivery({ target: pane, tmux: this.tmuxAdapter, listProcesses: this.listProcesses, expectedToken: resumeToken });
|
||||
// Refusal already has positive evidence; a later failed read cannot erase it.
|
||||
if (observation.state === "conflict" || observation.state === "idle_shell") return observation;
|
||||
const after = await this.tmuxAdapter.listPanes(sessionName);
|
||||
if (after.length > 1 || (after.length === 1 && after[0]!.id !== pane)) return { state: "conflict", detail: "The bound pane changed during delivery verification" };
|
||||
const currentPid = await this.tmuxAdapter.getPanePid(pane);
|
||||
if (panePid && currentPid && panePid !== currentPid) return { state: "conflict", detail: "The bound pane process changed during delivery verification" };
|
||||
return after.length === 0 || !sessionPid || !panePid || !currentPid ? unknown : observation;
|
||||
} catch { return unknown; }
|
||||
}
|
||||
|
||||
/** Shell label without positive native proof; not proof of an idle shell or stopped agent.
|
||||
* Null when no shell label is observed, or the expected native process is verified. */
|
||||
private async unverifiedShellForeground(sessionName: string, runtime: string, pane: string | null, resumeToken: string | null): Promise<string | null> {
|
||||
@@ -1451,12 +1497,11 @@ export class SessionTransport {
|
||||
return null;
|
||||
}
|
||||
if (!paneCommand || !isShellForeground(paneCommand)) return null;
|
||||
if ((runtime === "codex" || runtime === "claude-code") && pane) {
|
||||
// Reuse stable, foreground, pane-descendant proof. Claude fresh/resume and
|
||||
// Codex resume must name this session's token. Stale UI, a Node
|
||||
if (runtime === "codex" && pane) {
|
||||
// Reuse stable, foreground, pane-descendant Codex proof. A resumed process
|
||||
// must name this session's token. Stale UI, a Node
|
||||
// launcher alone, missing observations or a native process elsewhere cannot clear it.
|
||||
const verify = runtime === "codex" ? verifyCodexPaneProcess : verifyClaudePaneProcess;
|
||||
const native = await verify({ target: sessionName, tmux: this.tmuxAdapter,
|
||||
const native = await verifyCodexPaneProcess({ target: sessionName, tmux: this.tmuxAdapter,
|
||||
listProcesses: this.listProcesses, expectedToken: resumeToken });
|
||||
if (native && await this.tmuxAdapter.getPanePid(pane).catch(() => null) === native.panePid) return null;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ export const SETTINGS_VALID_KEYS = [
|
||||
"ui.preview.max_pins",
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
// The web UI and its terminal WebSocket are off unless this is true (read at daemon start).
|
||||
"ui.enabled",
|
||||
// OPR.0.4.0.1 — global cap on simultaneously-live terminals (default 2).
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
@@ -235,6 +237,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
|
||||
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"ui.terminal.max_live_terminals": { primary: "OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
@@ -316,6 +319,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"ui.terminal.max_live_terminals": ["ui", "terminal", "maxLiveTerminals"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
@@ -572,6 +576,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
case "ui.preview.max_pins": return 4;
|
||||
case "ui.preview.default_lines": return 50;
|
||||
case "ui.timezone": return "America/Los_Angeles";
|
||||
case "ui.enabled": return false;
|
||||
case "recovery.auto_drive_provider_prompts": return false;
|
||||
case "recovery.provider_auth_env_allowlist": return "";
|
||||
// V1 Phase 4 — Advisor default per universal-shell.md L83;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { promises as dnsPromises } from "node:dns";
|
||||
import { isIP } from "node:net";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { constantTimeEqual, detectTailscaleInterface } from "./auth-bearer-token.js";
|
||||
|
||||
/**
|
||||
* Browser boundary for /api/* (0.6.4). Runs once per request, before the remote
|
||||
* read-through and every route, WebSocket upgrades included:
|
||||
*
|
||||
* 1. Target name (Host). Accepted: `localhost`, IP literals, this machine's own
|
||||
* names (OS hostname, and its exact Tailscale MagicDNS name found by a reverse
|
||||
* lookup of its own tailnet IP at Tailscale's resolver) and OPENRIG_ALLOWED_HOSTS.
|
||||
* A valid configured bearer token in Authorization waives only this check.
|
||||
* 2. Browser origin (Origin), when present. Accepted: the daemon's own UI origin
|
||||
* while `ui.enabled` is on, or an entry in OPENRIG_ALLOWED_ORIGINS. Nothing
|
||||
* waives this check.
|
||||
*
|
||||
* Requests without Origin (CLI, TUI, MCP, daemon-to-daemon) meet only rule 1.
|
||||
* This is not complete browser isolation: a cross-site GET/HEAD that carries no
|
||||
* Origin and is addressed to an accepted name still reaches its handler.
|
||||
*/
|
||||
|
||||
export interface BrowserBoundaryOptions {
|
||||
webUiEnabled: boolean;
|
||||
/** Configured bearer tokens; a matching Authorization header waives only the target-name check. */
|
||||
bearerTokens: Array<string | null | undefined>;
|
||||
/** OPENRIG_ALLOWED_ORIGINS: browser origins only. */
|
||||
allowedOrigins?: string;
|
||||
/** OPENRIG_ALLOWED_HOSTS: target names only. */
|
||||
allowedHosts?: string;
|
||||
/** Returns this machine's own extra names (Tailscale MagicDNS). Absent: no discovery. */
|
||||
discoverSelfNames?: () => Promise<string[]>;
|
||||
discoveryTimeoutMs?: number;
|
||||
/** Minimum gap between lookups triggered by unrecognized names. */
|
||||
rediscoverAfterMs?: number;
|
||||
hostName?: () => string;
|
||||
warn?: (line: string) => void;
|
||||
now?: () => number;
|
||||
/** Test hook: one call per guarded request. */
|
||||
onDecision?: (decision: { outcome: "allow" | "refuse"; code?: BoundaryCode }) => void;
|
||||
}
|
||||
|
||||
export type BoundaryCode = "untrusted_host" | "browser_origin_refused";
|
||||
|
||||
export type ParsedHost =
|
||||
| { kind: "absent" }
|
||||
| { kind: "malformed" }
|
||||
| { kind: "ok"; hostname: string; port: string };
|
||||
|
||||
export type ParsedOrigin =
|
||||
| { kind: "opaque" }
|
||||
| { kind: "malformed" }
|
||||
| { kind: "ok"; scheme: string; hostname: string; port: string };
|
||||
|
||||
const HOST_HEADER = /^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)(?::(\d{1,5}))?$/;
|
||||
const DNS_NAME = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
||||
const TAILSCALE_RESOLVER = "100.100.100.100";
|
||||
const MAX_LOGGED_REFUSALS = 20;
|
||||
|
||||
/** Lowercase, drop IPv6 brackets and one trailing dot. */
|
||||
export function normalizeName(name: string): string {
|
||||
let out = name.trim().toLowerCase();
|
||||
if (out.startsWith("[") && out.endsWith("]")) out = out.slice(1, -1);
|
||||
if (out.endsWith(".")) out = out.slice(0, -1);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseHostHeader(value: string | undefined): ParsedHost {
|
||||
if (value === undefined) return { kind: "absent" };
|
||||
const match = HOST_HEADER.exec(value);
|
||||
if (!match) return { kind: "malformed" };
|
||||
const rawName = match[1]!;
|
||||
if (rawName.startsWith("[") && isIP(rawName.slice(1, -1)) !== 6) return { kind: "malformed" };
|
||||
const port = match[2] ?? "80";
|
||||
if (Number(port) < 1 || Number(port) > 65535) return { kind: "malformed" };
|
||||
const hostname = normalizeName(rawName);
|
||||
if (!hostname) return { kind: "malformed" };
|
||||
return { kind: "ok", hostname, port: String(Number(port)) };
|
||||
}
|
||||
|
||||
export function parseOrigin(value: string): ParsedOrigin {
|
||||
if (value === "null") return { kind: "opaque" };
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return { kind: "malformed" };
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) {
|
||||
return { kind: "malformed" };
|
||||
}
|
||||
// An origin is scheme://host[:port]; anything after the authority is not one.
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+\/?$/i.test(value)) return { kind: "malformed" };
|
||||
const scheme = url.protocol.slice(0, -1).toLowerCase();
|
||||
const port = url.port || (scheme === "https" ? "443" : scheme === "http" ? "80" : "");
|
||||
return { kind: "ok", scheme, hostname: normalizeName(url.hostname), port };
|
||||
}
|
||||
|
||||
function isLoopbackOrIp(hostname: string): boolean {
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
/** The OS hostname, its first label, and `<label>.local`. */
|
||||
export function osOwnNames(hostName: () => string = osHostname): string[] {
|
||||
const full = normalizeName(hostName() || "");
|
||||
if (!full) return [];
|
||||
const label = full.split(".")[0]!;
|
||||
return [...new Set([full, label, `${label}.local`])];
|
||||
}
|
||||
|
||||
function namesWithShortForm(names: string[]): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const raw of names) {
|
||||
const name = normalizeName(raw);
|
||||
if (!DNS_NAME.test(name)) continue;
|
||||
out.add(name);
|
||||
out.add(name.split(".")[0]!);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* This machine's exact Tailscale MagicDNS name: a reverse lookup of its own tailnet IP
|
||||
* (found from local interfaces), asked of Tailscale's resolver only. One query, bounded
|
||||
* by `timeoutMs`; no subprocess, no peer list. Returns [] when no tailnet IP is active.
|
||||
*/
|
||||
export async function discoverTailscaleSelfNames(opts: {
|
||||
timeoutMs: number;
|
||||
tailscaleIp?: () => string | null;
|
||||
reverse?: (ip: string, timeoutMs: number) => Promise<string[]>;
|
||||
}): Promise<string[]> {
|
||||
const ip = (opts.tailscaleIp ?? detectTailscaleInterface)();
|
||||
if (!ip) return [];
|
||||
const reverse = opts.reverse ?? (async (address: string, timeoutMs: number) => {
|
||||
const resolver = new dnsPromises.Resolver({ timeout: timeoutMs, tries: 1 });
|
||||
resolver.setServers([TAILSCALE_RESOLVER]);
|
||||
return resolver.reverse(address);
|
||||
});
|
||||
return namesWithShortForm(await reverse(ip, opts.timeoutMs));
|
||||
}
|
||||
|
||||
function parseAllowedHosts(raw: string | undefined): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const item of (raw ?? "").split(",")) {
|
||||
const name = normalizeName(item);
|
||||
if (name && (DNS_NAME.test(name) || isIP(name) !== 0)) out.add(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** OPENRIG_ALLOWED_ORIGINS: a full `http(s)://…` entry matches that exact origin;
|
||||
* any other `scheme://…` entry matches the identical string; a bare hostname matches
|
||||
* that hostname on any scheme and port. */
|
||||
function parseAllowedOrigins(raw: string | undefined): { origins: Set<string>; hostnames: Set<string>; exact: Set<string> } {
|
||||
const origins = new Set<string>();
|
||||
const hostnames = new Set<string>();
|
||||
const exact = new Set<string>();
|
||||
for (const item of (raw ?? "").split(",")) {
|
||||
const entry = item.trim().toLowerCase();
|
||||
if (!entry) continue;
|
||||
if (entry.startsWith("http://") || entry.startsWith("https://")) {
|
||||
const parsed = parseOrigin(entry.replace(/\/$/, ""));
|
||||
if (parsed.kind === "ok") origins.add(`${parsed.scheme}://${parsed.hostname}:${parsed.port}`);
|
||||
} else if (entry.includes("://")) {
|
||||
exact.add(entry);
|
||||
} else {
|
||||
hostnames.add(normalizeName(entry));
|
||||
}
|
||||
}
|
||||
return { origins, hostnames, exact };
|
||||
}
|
||||
|
||||
function shown(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = value.replace(/[\u0000-\u001f\u007f]/g, "?");
|
||||
return clean.length > 200 ? `${clean.slice(0, 200)}…` : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request header as the client sent it. Node adapters expose the original request as
|
||||
* `c.env.incoming`, whose `rawHeaders` keep duplicates and empty values; @hono/node-ws builds
|
||||
* its Request from the collapsed `incoming.headers`, which keep only the first duplicate and
|
||||
* drop empty values. Returns `undefined` when absent and `null` when duplicated (ambiguous).
|
||||
* Without a Node request (tests, other adapters) it falls back to the Request headers.
|
||||
*/
|
||||
export function rawHeader(c: Context, name: "host" | "origin"): string | undefined | null {
|
||||
const raw = (c.env as { incoming?: { rawHeaders?: unknown } } | undefined)?.incoming?.rawHeaders;
|
||||
if (!Array.isArray(raw)) return c.req.header(name);
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i + 1 < raw.length; i += 2) {
|
||||
if (String(raw[i]).toLowerCase() === name) values.push(String(raw[i + 1]));
|
||||
}
|
||||
if (values.length === 0) return undefined;
|
||||
return values.length === 1 ? values[0]! : null;
|
||||
}
|
||||
|
||||
function bearerFrom(header: string | undefined): string | null {
|
||||
if (!header) return null;
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
const token = match?.[1]?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
export function browserBoundary(options: BrowserBoundaryOptions): MiddlewareHandler {
|
||||
const now = options.now ?? Date.now;
|
||||
const warn = options.warn ?? ((line: string) => console.warn(line));
|
||||
const timeoutMs = options.discoveryTimeoutMs ?? 1500;
|
||||
const rediscoverAfterMs = options.rediscoverAfterMs ?? 10_000;
|
||||
const tokens = options.bearerTokens.filter((t): t is string => typeof t === "string" && t.length > 0);
|
||||
const allowedHosts = parseAllowedHosts(options.allowedHosts);
|
||||
const allowedOrigins = parseAllowedOrigins(options.allowedOrigins);
|
||||
const ownNames = new Set(osOwnNames(options.hostName));
|
||||
|
||||
let discovered = new Set<string>();
|
||||
let lastDiscoveryAt = Number.NEGATIVE_INFINITY;
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
const discover = (): Promise<void> => {
|
||||
const source = options.discoverSelfNames;
|
||||
if (!source) return Promise.resolve();
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const names = await Promise.race([
|
||||
source(),
|
||||
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), timeoutMs + 250); }),
|
||||
]);
|
||||
// A completed lookup replaces the set; a failed one keeps the previous names.
|
||||
discovered = new Set(namesWithShortForm(names));
|
||||
} catch {
|
||||
// Unavailable, malformed or slow discovery never blocks loopback/IP use.
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
lastDiscoveryAt = now();
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
};
|
||||
if (options.discoverSelfNames) void discover();
|
||||
|
||||
const knownTarget = (hostname: string): boolean =>
|
||||
isLoopbackOrIp(hostname) || ownNames.has(hostname) || discovered.has(hostname) || allowedHosts.has(hostname);
|
||||
|
||||
const acceptedTarget = async (hostname: string): Promise<boolean> => {
|
||||
if (knownTarget(hostname)) return true;
|
||||
if (options.discoverSelfNames && (inflight || now() - lastDiscoveryAt >= rediscoverAfterMs)) {
|
||||
await discover();
|
||||
return knownTarget(hostname);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const explicitlyAllowedOrigin = (raw: string, origin: ParsedOrigin): boolean => {
|
||||
if (allowedOrigins.exact.has(raw.trim().toLowerCase())) return true;
|
||||
if (origin.kind !== "ok") return false;
|
||||
if (allowedOrigins.hostnames.has(origin.hostname)) return true;
|
||||
return allowedOrigins.origins.has(`${origin.scheme}://${origin.hostname}:${origin.port}`);
|
||||
};
|
||||
|
||||
const logged = new Set<string>();
|
||||
let suppressionLogged = false;
|
||||
const logRefusal = (c: Context, code: BoundaryCode, value: string): void => {
|
||||
const key = `${code}|${value}`;
|
||||
if (logged.has(key)) return;
|
||||
if (logged.size >= MAX_LOGGED_REFUSALS) {
|
||||
if (!suppressionLogged) {
|
||||
suppressionLogged = true;
|
||||
warn("[openrig] browser boundary: further distinct refusals are not logged");
|
||||
}
|
||||
return;
|
||||
}
|
||||
logged.add(key);
|
||||
warn(`[openrig] browser boundary refused ${code}: ${value} (${c.req.method} ${shown(c.req.path)})`);
|
||||
};
|
||||
|
||||
const refuse = (c: Context, code: BoundaryCode, value: string, error: string) => {
|
||||
logRefusal(c, code, value);
|
||||
options.onDecision?.({ outcome: "refuse", code });
|
||||
return c.json({ error, code }, 403);
|
||||
};
|
||||
|
||||
return async (c, next) => {
|
||||
const hostHeader = rawHeader(c, "host");
|
||||
// A duplicated (ambiguous) Host is malformed; an explicitly empty one fails parsing.
|
||||
const host = hostHeader === null ? { kind: "malformed" as const } : parseHostHeader(hostHeader);
|
||||
let hostAccepted = false;
|
||||
if (host.kind === "malformed") {
|
||||
const value = shown(hostHeader === null ? "(duplicate Host)" : hostHeader ?? "");
|
||||
return refuse(c, "untrusted_host", value,
|
||||
`This OpenRig daemon refused a request with an unusable Host header ("${value}"). Address the daemon by localhost, an IP address or this machine's own name.`);
|
||||
}
|
||||
if (host.kind === "ok") {
|
||||
hostAccepted = await acceptedTarget(host.hostname);
|
||||
if (!hostAccepted) {
|
||||
const presented = bearerFrom(c.req.header("Authorization"));
|
||||
const tokenValid = presented !== null && tokens.some((token) => constantTimeEqual(presented, token));
|
||||
if (!tokenValid) {
|
||||
const value = shown(host.hostname);
|
||||
return refuse(c, "untrusted_host", value,
|
||||
`This OpenRig daemon does not accept requests addressed to "${value}". Use localhost, an IP address or this machine's own name, or add this name to OPENRIG_ALLOWED_HOSTS in the daemon's environment and restart the daemon.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawOrigin = rawHeader(c, "origin");
|
||||
// A duplicated Origin is malformed; an explicitly empty one is present and fails parsing.
|
||||
const originHeader = rawOrigin === null ? "(duplicate Origin)" : rawOrigin;
|
||||
if (originHeader !== undefined) {
|
||||
const origin = rawOrigin === null ? { kind: "malformed" as const } : parseOrigin(originHeader);
|
||||
const ownUi = options.webUiEnabled && origin.kind === "ok" && origin.scheme === "http"
|
||||
&& host.kind === "ok" && hostAccepted
|
||||
&& origin.hostname === host.hostname && origin.port === host.port;
|
||||
if (!ownUi && !explicitlyAllowedOrigin(originHeader, origin)) {
|
||||
const value = shown(originHeader);
|
||||
return refuse(c, "browser_origin_refused", value,
|
||||
`This OpenRig daemon refuses browser requests from origin "${value}". Open the OpenRig UI from the daemon's own address with the web UI enabled (rig config set ui.enabled true, then restart the daemon), or add this exact origin to OPENRIG_ALLOWED_ORIGINS in the daemon's environment and restart. Web pages on other origins cannot read this response.`);
|
||||
}
|
||||
}
|
||||
|
||||
options.onDecision?.({ outcome: "allow" });
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -346,6 +346,8 @@ function podAssemblerFsOps(): PodAssemblerFsOps {
|
||||
return {
|
||||
...assemblerFsOps(),
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
readFileBuffer: (p) => fs.readFileSync(p),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
listFiles: (dir) => realFsOps().listFiles!(dir),
|
||||
};
|
||||
@@ -365,8 +367,7 @@ function pluginsRouterFsOps(): PluginsRouterFsOps {
|
||||
function workflowSpecsRouterFsOps(): WorkflowSpecsRouterFsOps {
|
||||
return {
|
||||
exists: (p) => fs.existsSync(p),
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFile: (s, d) => fs.copyFileSync(s, d),
|
||||
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
|
||||
};
|
||||
}
|
||||
@@ -592,8 +593,7 @@ async function routePluginsAfterBootstrap(bundlePath: string): Promise<RoutePlug
|
||||
function skillsRouterFsOps(): SkillsRouterFsOps {
|
||||
return {
|
||||
exists: (p) => fs.existsSync(p),
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFile: (s, d) => fs.copyFileSync(s, d),
|
||||
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ function getStartupContext(db: Database.Database, nodeId: string): StartupContex
|
||||
required: candidate["required"] !== false,
|
||||
path: typeof candidate["path"] === "string" ? candidate["path"] : null,
|
||||
deliveryHint: typeof candidate["deliveryHint"] === "string" ? candidate["deliveryHint"] : null,
|
||||
ownerRoot: typeof candidate["ownerRoot"] === "string" ? candidate["ownerRoot"] : null,
|
||||
}];
|
||||
}),
|
||||
projectionEntries: projectionEntries.value.flatMap((entry) => {
|
||||
@@ -118,6 +119,7 @@ function getStartupContext(db: Database.Database, nodeId: string): StartupContex
|
||||
absolutePath: candidate["absolutePath"].trim(),
|
||||
effectiveId: typeof candidate["effectiveId"] === "string" ? candidate["effectiveId"] : null,
|
||||
category: typeof candidate["category"] === "string" ? candidate["category"] : null,
|
||||
sourcePath: typeof candidate["sourcePath"] === "string" ? candidate["sourcePath"] : null,
|
||||
}];
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -11,31 +11,10 @@ import {
|
||||
const MAX_EARLY_TERMINAL_FRAMES = 32;
|
||||
const MAX_EARLY_TERMINAL_FRAME_BYTES = 256 * 1024;
|
||||
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: Parameters<typeof import("@hono/node-ws").createNodeWebSocket>[0] extends { app: infer _A } ? never : never,
|
||||
opts: { bearerToken: string | null },
|
||||
): void;
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: (createHandler: (c: unknown) => unknown) => unknown,
|
||||
opts: { bearerToken: string | null; livenessIntervalMs?: number },
|
||||
): void {
|
||||
const terminalAuthMiddleware = async (c: { req: { header(name: string): string | undefined; query(name: string): string | undefined }; json(data: unknown, status: number): unknown }, next: () => Promise<void>) => {
|
||||
const upgrade = c.req.header("Upgrade");
|
||||
if (upgrade?.toLowerCase() === "websocket") {
|
||||
const origin = c.req.header("Origin");
|
||||
if (origin) {
|
||||
try {
|
||||
const originHost = new URL(origin).hostname;
|
||||
const requestHost = c.req.header("Host")?.split(":")[0] ?? "";
|
||||
const allowed = originHost === requestHost || originHost === "localhost" || originHost === "127.0.0.1";
|
||||
if (!allowed) return c.json({ error: "origin_rejected", hint: `Origin ${origin} does not match host` }, 403);
|
||||
} catch {
|
||||
return c.json({ error: "origin_rejected", hint: "Malformed Origin header" }, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** The WebSocket route's guard: the terminal bearer token when one is set. Origin and target name
|
||||
* are checked once for every /api request, upgrades included, by middleware/browser-boundary.ts. */
|
||||
export function terminalAuthMiddleware(opts: { bearerToken: string | null }) {
|
||||
return async (c: { req: { header(name: string): string | undefined; query(name: string): string | undefined }; json(data: unknown, status: number): unknown }, next: () => Promise<void>) => {
|
||||
const token = opts.bearerToken;
|
||||
if (!token) { await next(); return; }
|
||||
const header = c.req.header("Authorization") ?? c.req.header("authorization");
|
||||
@@ -47,6 +26,26 @@ export function registerTerminalWs(
|
||||
if (queryToken && constantTimeEqual(queryToken.trim(), token)) { await next(); return; }
|
||||
return c.json({ error: "unauthorized", hint: "Pass terminal token via Authorization header or ?token= query" }, 401);
|
||||
};
|
||||
}
|
||||
|
||||
/** With the web UI off the WebSocket is not registered, but this same guard still runs in front of the HTTP
|
||||
* terminal routes it matched before (GET /api/terminal/views, /preview, /status), so their behaviour is unchanged. */
|
||||
export function registerTerminalAuthOnly(app: Hono, opts: { bearerToken: string | null }): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(app as any).get("/api/terminal/:sessionName", terminalAuthMiddleware(opts));
|
||||
}
|
||||
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: Parameters<typeof import("@hono/node-ws").createNodeWebSocket>[0] extends { app: infer _A } ? never : never,
|
||||
opts: { bearerToken: string | null },
|
||||
): void;
|
||||
export function registerTerminalWs(
|
||||
app: Hono,
|
||||
upgradeWebSocket: (createHandler: (c: unknown) => unknown) => unknown,
|
||||
opts: { bearerToken: string | null; livenessIntervalMs?: number },
|
||||
): void {
|
||||
const terminalAuth = terminalAuthMiddleware(opts);
|
||||
|
||||
// One daemon-owned broker registry shared across every WebSocket connection,
|
||||
// created lazily from the first connection's tmux adapter (a daemon
|
||||
@@ -63,7 +62,7 @@ export function registerTerminalWs(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(app as any).get(
|
||||
"/api/terminal/:sessionName",
|
||||
terminalAuthMiddleware,
|
||||
terminalAuth,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(upgradeWebSocket as any)((c: any) => {
|
||||
const sessionName = decodeURIComponent(c.req.param("sessionName")!);
|
||||
|
||||
@@ -115,7 +115,7 @@ import { scopesRoutes } from "./routes/scopes.js";
|
||||
import { telemetryRoutes } from "./routes/telemetry.js";
|
||||
import { proofRoutes } from "./routes/proof.js";
|
||||
import { scopeApproveRoutes } from "./routes/scope-approve.js";
|
||||
import { registerTerminalWs } from "./routes/terminal-ws.js";
|
||||
import { registerTerminalAuthOnly, registerTerminalWs } from "./routes/terminal-ws.js";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import { steeringRoutes } from "./routes/steering.js";
|
||||
import { healthSummaryRoutes } from "./routes/health-summary.js";
|
||||
@@ -140,6 +140,7 @@ import { envRoutes } from "./routes/env.js";
|
||||
import type { RigLifecycleService } from "./domain/rig-lifecycle-service.js";
|
||||
import { seatRoutes } from "./routes/seat.js";
|
||||
import { createRouteTimingMiddleware } from "./domain/route-timing-recorder.js";
|
||||
import { browserBoundary, type BrowserBoundaryOptions } from "./middleware/browser-boundary.js";
|
||||
|
||||
export interface AppDeps {
|
||||
proofSourceWatch?: import("./domain/proof/source-watch.js").ProofSourceWatch;
|
||||
@@ -291,6 +292,13 @@ export interface AppDeps {
|
||||
missionControlBearerToken?: string | null;
|
||||
terminalBearerToken?: string | null;
|
||||
enableNodeWebSocket?: boolean;
|
||||
/** `ui.enabled`: serve the web UI pages and its terminal WebSocket. Off unless true; /api routes are unaffected. */
|
||||
webUiEnabled?: boolean;
|
||||
/** This machine's own extra names for the /api browser boundary (createDaemon wires the
|
||||
* Tailscale MagicDNS self-name lookup). Absent: loopback, IP literals and OS names only. */
|
||||
selfNameDiscovery?: () => Promise<string[]>;
|
||||
/** Test hook: one call per /api browser-boundary decision. */
|
||||
browserBoundaryObserver?: BrowserBoundaryOptions["onDecision"];
|
||||
specReviewService?: SpecReviewService;
|
||||
specLibraryService?: SpecLibraryService;
|
||||
/**
|
||||
@@ -395,6 +403,9 @@ function resolveDefaultUiDistDir(): string {
|
||||
return nodePath.resolve(nodePath.dirname(fileURLToPath(import.meta.url)), "..", "..", "ui", "dist");
|
||||
}
|
||||
|
||||
export const WEB_UI_OFF_MESSAGE =
|
||||
"The OpenRig web UI is off. To turn it on, run `rig config set ui.enabled true`, then stop and start the daemon (`rig daemon stop`, `rig daemon start`).\n";
|
||||
|
||||
function safeResolveUiPath(uiDistDir: string, requestPath: string): string | null {
|
||||
const relativePath = requestPath.replace(/^\/+/, "") || "index.html";
|
||||
const resolvedPath = nodePath.resolve(uiDistDir, relativePath);
|
||||
@@ -622,6 +633,17 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.use("*", createSlowOpRequestMiddleware(deps.slowOpRecorder));
|
||||
}
|
||||
|
||||
// 0.6.4 browser boundary: target name and browser Origin, checked once per /api
|
||||
// request (WebSocket upgrades included), before the remote read-through and every route.
|
||||
app.use("/api/*", browserBoundary({
|
||||
webUiEnabled: deps.webUiEnabled === true,
|
||||
bearerTokens: [deps.terminalBearerToken, deps.missionControlBearerToken],
|
||||
allowedOrigins: process.env.OPENRIG_ALLOWED_ORIGINS,
|
||||
allowedHosts: process.env.OPENRIG_ALLOWED_HOSTS,
|
||||
discoverSelfNames: deps.selfNameDiscovery,
|
||||
onDecision: deps.browserBoundaryObserver,
|
||||
}));
|
||||
|
||||
// OPR.0.4.6.MH2 FR-2/FR-7 — the single-host READ-THROUGH edge (the read
|
||||
// twin of the mission-control remote-forward). Consumes a `?host=<id>`
|
||||
// envelope on allowlisted GET reads; refuses non-GET / non-allowlisted
|
||||
@@ -721,11 +743,14 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/compaction", compactionRoutes({ bearerToken: deps.terminalBearerToken ?? null }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let injectWebSocket: (server: any) => void = () => {};
|
||||
if (deps.enableNodeWebSocket) {
|
||||
// The terminal WebSocket only serves the web UI, so it exists only while the web UI is on.
|
||||
if (deps.enableNodeWebSocket && deps.webUiEnabled === true) {
|
||||
const ws = createNodeWebSocket({ app });
|
||||
injectWebSocket = ws.injectWebSocket as never;
|
||||
_lastInjectWebSocket = injectWebSocket;
|
||||
registerTerminalWs(app, ws.upgradeWebSocket as never, { bearerToken: deps.terminalBearerToken ?? null });
|
||||
} else if (deps.enableNodeWebSocket) {
|
||||
registerTerminalAuthOnly(app, { bearerToken: deps.terminalBearerToken ?? null });
|
||||
}
|
||||
app.route("/api/activity", activityRoutes);
|
||||
app.route("/api/ask", askRoutes);
|
||||
@@ -822,6 +847,11 @@ export function createApp(deps: AppDeps): Hono {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
if (deps.webUiEnabled !== true) {
|
||||
c.header("X-OpenRig-Web-UI", "off");
|
||||
return c.text(WEB_UI_OFF_MESSAGE, 404);
|
||||
}
|
||||
|
||||
if (!hasUiBundle) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { discoverTailscaleSelfNames } from "./middleware/browser-boundary.js";
|
||||
import { configureShadowCapture } from "./domain/shadow-capture.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
|
||||
import { queueRecoveryOwnsWake } from "./domain/queue-wake-ladder.js";
|
||||
@@ -195,6 +196,8 @@ interface DaemonOptions {
|
||||
* terminal preview/transport/websocket routes.
|
||||
*/
|
||||
terminalBearerToken?: string | null;
|
||||
/** Overrides the `ui.enabled` setting (tests). */
|
||||
webUiEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface DaemonResult {
|
||||
@@ -2359,6 +2362,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// OPR.0.5.6.1 — bind the delivery policies' late gateway ref.
|
||||
lateGatewayDispatch.fn = (op, ref, payload, opts) => gatewaySubsystem.dispatch(op, ref, payload, opts);
|
||||
|
||||
// `ui.enabled` (default off): the web UI pages and its terminal WebSocket. Read once at start.
|
||||
deps.webUiEnabled = opts?.webUiEnabled ?? new ContextPackSettingsStore().resolveOne("ui.enabled").value === true;
|
||||
// /api browser boundary: this machine's exact Tailscale MagicDNS name, looked up on demand.
|
||||
deps.selfNameDiscovery = () => discoverTailscaleSelfNames({ timeoutMs: 1500 });
|
||||
const { app, injectWebSocket } = createAppWithWebSocket(deps);
|
||||
|
||||
return { app, db, deps, contextMonitor, eventLoopMonitor, injectWebSocket };
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Browser boundary on a real Node listener (@hono/node-server) and the real WebSocket upgrade
|
||||
// path (@hono/node-ws), plus real CLI/TUI/daemon-to-daemon request constructors. Inert handlers only.
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import WebSocket from "ws";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
import { terminalAuthMiddleware } from "../src/routes/terminal-ws.js";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { remoteJsonRequest } from "../src/domain/hosts/remote-daemon-http.js";
|
||||
import { DaemonClient as CliDaemonClient } from "../../cli/src/client.js";
|
||||
import { DaemonClient as TuiDaemonClient } from "../../tui/src/daemon-client.js";
|
||||
|
||||
const SELF = "openrig-vm.taile8a08.ts.net";
|
||||
type Server = ReturnType<typeof serve>;
|
||||
|
||||
function listen(app: Hono, hostname: string): Promise<{ server: Server; port: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = serve({ fetch: app.fetch, port: 0, hostname }, (info) => resolve({ server, port: info.port }));
|
||||
(server as unknown as net.Server).once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function raw(port: number, headers: Record<string, string>, path = "/api/ping", address = "127.0.0.1"): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: address, port, path, method: "GET", headers, setHost: !("Host" in headers) }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (d) => { body += d; });
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function rawSocket(port: number, request: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.connect(port, "127.0.0.1", () => sock.write(request));
|
||||
let data = "";
|
||||
sock.on("data", (d) => { data += d; });
|
||||
sock.on("close", () => resolve(data.split("\r\n")[0] ?? ""));
|
||||
sock.on("error", () => resolve(data.split("\r\n")[0] ?? "error"));
|
||||
setTimeout(() => sock.destroy(), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
describe("mirror of the server.ts order on a real listener: boundary, then terminal guard, then upgrade", () => {
|
||||
const decisions: string[] = [];
|
||||
let handled = 0;
|
||||
let opened = 0;
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
let v6: { server: Server; port: number } | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = new Hono();
|
||||
const ws = createNodeWebSocket({ app });
|
||||
app.use("/api/*", browserBoundary({
|
||||
webUiEnabled: true, bearerTokens: [], hostName: () => "box-1.local",
|
||||
discoverSelfNames: async () => [SELF], warn: () => {},
|
||||
onDecision: (d) => decisions.push(d.code ?? d.outcome),
|
||||
}));
|
||||
app.get("/api/ping", (c) => { handled++; return c.json({ ok: true }); });
|
||||
app.get("/api/terminal/:s", terminalAuthMiddleware({ bearerToken: null }) as never,
|
||||
ws.upgradeWebSocket(() => ({ onOpen: (_e, socket) => { opened++; socket.close(); } })));
|
||||
({ server, port } = await listen(app, "127.0.0.1"));
|
||||
ws.injectWebSocket(server);
|
||||
try { v6 = await listen(app, "::1"); } catch { v6 = null; }
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((r) => (server as unknown as net.Server).close(() => r()));
|
||||
if (v6) await new Promise<void>((r) => (v6!.server as unknown as net.Server).close(() => r()));
|
||||
});
|
||||
|
||||
it("actual Node headers: accepted names reach the inert handler once each; others never", async () => {
|
||||
const before = handled;
|
||||
for (const host of [`127.0.0.1:${port}`, `localhost:${port}`, `box-1.local:${port}`, `${SELF}:${port}`, `openrig-vm:${port}`]) {
|
||||
expect((await raw(port, { Host: host })).status, host).toBe(200);
|
||||
}
|
||||
expect(handled - before).toBe(5);
|
||||
// @hono/node-server 1.19.11 answers 400 "Invalid host header" before the app when the URL-normalized
|
||||
// hostname differs from the raw Host (case); a lowercase trailing dot passes through and the
|
||||
// boundary normalizes it.
|
||||
const upper = await raw(port, { Host: `LOCALHOST:${port}` });
|
||||
const dotted = await raw(port, { Host: `localhost.:${port}` });
|
||||
console.log(`ADAPTER-HOST-FORM LOCALHOST -> ${upper.status}; localhost. -> ${dotted.status}`);
|
||||
expect(upper.status).toBe(400);
|
||||
expect(dotted.status).toBe(200);
|
||||
expect(handled - before).toBe(6);
|
||||
for (const host of [`evil.example:${port}`, `other-vm.taile8a08.ts.net:${port}`]) {
|
||||
const r = await raw(port, { Host: host });
|
||||
expect(r.status, host).toBe(403);
|
||||
expect(JSON.parse(r.body).code).toBe("untrusted_host");
|
||||
}
|
||||
for (const host of ["a b", `x@y:${port}`]) {
|
||||
const r = await raw(port, { Host: host });
|
||||
console.log(`MALFORMED-HOST-FORM ${JSON.stringify(host)} -> ${r.status} ${r.body.slice(0, 80)}`);
|
||||
expect(r.status, host).not.toBe(200);
|
||||
}
|
||||
expect(handled - before).toBe(6);
|
||||
});
|
||||
|
||||
it("IPv6 loopback listener: [::1] Host is accepted", async () => {
|
||||
if (!v6) { console.log("IPV6-LISTENER: unavailable on this host (control not run)"); return; }
|
||||
const r = await raw(v6.port, { Host: `[::1]:${v6.port}` }, "/api/ping", "::1");
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("records what the real listener does with duplicate, missing and HTTP/1.0 Host forms", async () => {
|
||||
const dup = await rawSocket(port, `GET /api/ping HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nHost: evil.example\r\nConnection: close\r\n\r\n`);
|
||||
const missing11 = await rawSocket(port, "GET /api/ping HTTP/1.1\r\nConnection: close\r\n\r\n");
|
||||
const http10 = await rawSocket(port, "GET /api/ping HTTP/1.0\r\n\r\n");
|
||||
const dupOrigin = await rawSocket(port, `GET /api/ping HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nOrigin: http://127.0.0.1:${port}\r\nOrigin: http://evil.example\r\nConnection: close\r\n\r\n`);
|
||||
console.log(`LISTENER-FORMS duplicateHost=[${dup}] missingHost11=[${missing11}] http10NoHost=[${http10}] duplicateOrigin=[${dupOrigin}]`);
|
||||
expect(dup).not.toMatch(/ 200 /);
|
||||
expect(dupOrigin).not.toMatch(/ 200 /);
|
||||
});
|
||||
|
||||
it("real upgrade path: the guard runs exactly once per upgrade; refused upgrades never open", async () => {
|
||||
const attempt = (origin: string | undefined, host?: string) => new Promise<{ opened: boolean; status?: number }>((resolve) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (host) headers["Host"] = host;
|
||||
const sock = new WebSocket(`ws://127.0.0.1:${port}/api/terminal/s1`, { headers, ...(origin ? { origin } : {}) });
|
||||
sock.on("open", () => { sock.close(); resolve({ opened: true }); });
|
||||
sock.on("unexpected-response", (_req, res) => { resolve({ opened: false, status: res.statusCode }); });
|
||||
sock.on("error", () => resolve({ opened: false }));
|
||||
});
|
||||
const d0 = decisions.length;
|
||||
const o0 = opened;
|
||||
expect(await attempt(`http://127.0.0.1:${port}`)).toEqual({ opened: true });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(decisions.length - d0).toBe(1);
|
||||
expect(opened - o0).toBe(1);
|
||||
const d1 = decisions.length;
|
||||
expect(await attempt("http://evil.example")).toEqual({ opened: false, status: 403 });
|
||||
expect(await attempt(undefined, `evil.example:${port}`)).toEqual({ opened: false, status: 403 });
|
||||
expect(await attempt(`http://localhost:5173`, `localhost:${port}`)).toEqual({ opened: false, status: 403 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(decisions.length - d1).toBe(3);
|
||||
expect(opened - o0).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real request constructors against the real route table on a real listener", () => {
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
beforeAll(async () => {
|
||||
const db = createFullTestDb();
|
||||
const { app } = createTestApp(db);
|
||||
({ server, port } = await listen(app as unknown as Hono, "127.0.0.1"));
|
||||
});
|
||||
afterAll(async () => { await new Promise<void>((r) => (server as unknown as net.Server).close(() => r())); });
|
||||
|
||||
it("CLI DaemonClient (no Origin) reaches /api/ps by IP and localhost", async () => {
|
||||
for (const base of [`http://127.0.0.1:${port}`, `http://localhost:${port}`]) {
|
||||
const res = await new CliDaemonClient(base).get<unknown[]>("/api/ps?includeArchived=true");
|
||||
expect(res.status, base).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("records whether Node fetch honours an explicit Host header (needed to present an unlisted name without DNS)", async () => {
|
||||
const res = await new CliDaemonClient(`http://127.0.0.1:${port}`, { headers: { Host: "unlisted.example" } }).get<{ code?: string; error?: string }>("/api/ps");
|
||||
console.log(`CLI-HOST-OVERRIDE status=${res.status} code=${(res.data as { code?: string })?.code ?? "-"}`);
|
||||
if (res.status === 403) {
|
||||
expect(res.data.code).toBe("untrusted_host");
|
||||
expect(res.data.error).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
}
|
||||
});
|
||||
|
||||
it("TUI client read path carries the refusal sentence; normal reads pass", async () => {
|
||||
const ok = new TuiDaemonClient({ baseUrl: `http://127.0.0.1:${port}` } as never);
|
||||
await expect(ok.healthFindings(5)).resolves.toBeDefined();
|
||||
const named = new TuiDaemonClient({ baseUrl: `http://127.0.0.1:${port}`, headers: { Host: "unlisted.example" } } as never);
|
||||
const err = await named.healthFindings(5).then(() => null, (e: Error) => e.message);
|
||||
console.log(`TUI-READ-REFUSAL ${err ?? "no refusal (Host override not honoured)"}`);
|
||||
if (err) expect(err).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
});
|
||||
|
||||
it("daemon-to-daemon remoteJsonRequest (no Origin) reaches the daemon by IP and localhost", async () => {
|
||||
for (const url of [`http://127.0.0.1:${port}`, `http://localhost:${port}`]) {
|
||||
const r = await remoteJsonRequest({ id: "peer", transport: "http", url }, "/api/ps", { method: "GET", timeoutMs: 3000, env: {} });
|
||||
expect(r.ok, url).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// F1/F2 controls on actual Node listeners. F1 drives the production createAppWithWebSocket and its
|
||||
// registered terminal route with raw socket requests, so duplicate and empty header forms reach the
|
||||
// adapters exactly as sent. F2 checks the refusal log's emitted content.
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import net from "node:net";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type Database from "better-sqlite3";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
import { createFullTestDb, createTestApp, mockTmuxAdapter } from "./helpers/test-app.js";
|
||||
|
||||
type Server = ReturnType<typeof serve>;
|
||||
const listen = (fetch: Hono["fetch"]) => new Promise<{ server: Server; port: number }>((resolve) => {
|
||||
const server = serve({ fetch, port: 0, hostname: "127.0.0.1" }, (info) => resolve({ server, port: info.port }));
|
||||
});
|
||||
const close = (s: Server) => new Promise<void>((r) => (s as unknown as net.Server).close(() => r()));
|
||||
|
||||
/** Send raw request lines and return the status line. */
|
||||
function rawRequest(port: number, requestLine: string, headerLines: string[]): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.connect(port, "127.0.0.1", () => sock.write(`${requestLine}\r\n${headerLines.join("\r\n")}\r\n\r\n`));
|
||||
let data = "";
|
||||
const done = () => resolve(data.split("\r\n")[0] ?? "");
|
||||
sock.on("data", (d) => { data += d; if (data.includes("\r\n")) { sock.destroy(); } });
|
||||
sock.on("close", done);
|
||||
sock.on("error", done);
|
||||
setTimeout(() => sock.destroy(), 1500);
|
||||
});
|
||||
}
|
||||
const UPGRADE = ["Connection: Upgrade", "Upgrade: websocket", "Sec-WebSocket-Version: 13", `Sec-WebSocket-Key: ${Buffer.from("bb-ws-key-123456").toString("base64")}`];
|
||||
const settle = () => new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
describe("F1: production createAppWithWebSocket + registered terminal route, raw header forms", () => {
|
||||
let db: Database.Database;
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
let hasSessionCalls = 0;
|
||||
const decisions: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
db = createFullTestDb();
|
||||
const ok = async () => ({ ok: true });
|
||||
const tmux = {
|
||||
...mockTmuxAdapter(),
|
||||
hasSession: async () => { hasSessionCalls++; return true; },
|
||||
setWindowOption: ok, startPipePane: ok, stopPipePane: ok, sendKeys: ok, sendText: ok, resizeWindow: ok,
|
||||
};
|
||||
const built = createTestApp(db, {
|
||||
tmux: tmux as never,
|
||||
withWebSocket: true,
|
||||
appDeps: { webUiEnabled: true, browserBoundaryObserver: (d) => decisions.push(d.code ?? d.outcome) },
|
||||
});
|
||||
({ server, port } = await listen(built.app.fetch as Hono["fetch"]));
|
||||
built.injectWebSocket!(server);
|
||||
});
|
||||
afterAll(async () => { await close(server); db.close(); });
|
||||
|
||||
async function upgradeCase(headers: string[]) {
|
||||
const d0 = decisions.length, h0 = hasSessionCalls;
|
||||
const status = await rawRequest(port, "GET /api/terminal/seat-1 HTTP/1.1", [...headers, ...UPGRADE]);
|
||||
await settle();
|
||||
return { status, decisions: decisions.slice(d0), routeEffects: hasSessionCalls - h0 };
|
||||
}
|
||||
|
||||
it("positive control: own UI origin upgrades once, one allow decision, route effect present", async () => {
|
||||
const r = await upgradeCase([`Host: 127.0.0.1:${port}`, `Origin: http://127.0.0.1:${port}`]);
|
||||
console.log("F1-UPGRADE own", JSON.stringify(r));
|
||||
expect(r.status).toContain("101");
|
||||
expect(r.decisions).toEqual(["allow"]);
|
||||
expect(r.routeEffects).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("foreign origin control: 403, one refusal, zero route effects", async () => {
|
||||
const r = await upgradeCase([`Host: 127.0.0.1:${port}`, "Origin: http://evil.example"]);
|
||||
console.log("F1-UPGRADE foreign", JSON.stringify(r));
|
||||
expect(r.status).toContain("403");
|
||||
expect(r.decisions).toEqual(["browser_origin_refused"]);
|
||||
expect(r.routeEffects).toBe(0);
|
||||
});
|
||||
|
||||
for (const [name, headers, code] of [
|
||||
["duplicate Host", (p: number) => [`Host: 127.0.0.1:${p}`, "Host: evil.example", `Origin: http://127.0.0.1:${p}`], "untrusted_host"],
|
||||
["empty Host", (p: number) => ["Host: ", `Origin: http://127.0.0.1:${p}`], "untrusted_host"],
|
||||
["empty Host, no Origin", (_p: number) => ["Host: "], "untrusted_host"],
|
||||
["empty Origin", (p: number) => [`Host: 127.0.0.1:${p}`, "Origin: "], "browser_origin_refused"],
|
||||
["duplicate Origin", (p: number) => [`Host: 127.0.0.1:${p}`, `Origin: http://127.0.0.1:${p}`, "Origin: http://evil.example"], "browser_origin_refused"],
|
||||
] as const) {
|
||||
it(`${name}: refused on the upgrade path with one decision and zero route effects`, async () => {
|
||||
const r = await upgradeCase(headers(port));
|
||||
console.log(`F1-UPGRADE ${name}`, JSON.stringify(r));
|
||||
expect(r.status).toContain("403");
|
||||
expect(r.decisions).toEqual([code]);
|
||||
expect(r.routeEffects).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("the same shapes over plain HTTP are refused as well", async () => {
|
||||
for (const headers of [
|
||||
[`Host: 127.0.0.1:${port}`, "Host: evil.example"],
|
||||
["Host: "],
|
||||
[`Host: 127.0.0.1:${port}`, "Origin: "],
|
||||
]) {
|
||||
const status = await rawRequest(port, "GET /api/ps HTTP/1.1", [...headers, "Connection: close"]);
|
||||
console.log("F1-HTTP", JSON.stringify(headers), status);
|
||||
expect(status).not.toContain(" 200 ");
|
||||
}
|
||||
});
|
||||
|
||||
it("genuinely absent Host (HTTP/1.0, non-browser) is still admitted", async () => {
|
||||
const status = await rawRequest(port, "GET /api/ps HTTP/1.0", []);
|
||||
expect(status).toContain("200");
|
||||
});
|
||||
});
|
||||
|
||||
describe("F2: refusal log content on an actual listener", () => {
|
||||
const lines: string[] = [];
|
||||
let server: Server;
|
||||
let port = 0;
|
||||
beforeAll(async () => {
|
||||
const app = new Hono();
|
||||
app.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], warn: (l) => lines.push(l) }));
|
||||
app.all("/api/*", (c) => c.json({ ok: true }));
|
||||
({ server, port } = await listen(app.fetch));
|
||||
});
|
||||
afterAll(async () => { await close(server); });
|
||||
|
||||
it("an encoded control character in the path is replaced in the log; a long path is bounded; the query never appears", async () => {
|
||||
await rawRequest(port, "GET /api/a%1Bb?token=SECRET HTTP/1.1", ["Host: evil-1.example", "Connection: close"]);
|
||||
await rawRequest(port, `GET /api/${"x".repeat(9000)} HTTP/1.1`, ["Host: evil-2.example", "Connection: close"]);
|
||||
console.log("F2-LOG", JSON.stringify(lines.map((l) => ({ length: l.length, hasEsc: l.includes("\u001b"), head: l.slice(0, 120) }))));
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).not.toContain("\u001b");
|
||||
expect(lines[0]).toContain("/api/a?b");
|
||||
expect(lines[0]).not.toContain("SECRET");
|
||||
expect(lines[1]!.length).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Browser boundary on the real route table (createApp via createTestApp): refused requests
|
||||
// reach no handler and no remote forward; admitted requests reach the inert handler once.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
|
||||
type Call = { method: string; args: unknown[] };
|
||||
|
||||
function inertTransport(calls: Call[]) {
|
||||
return {
|
||||
resolveSessions: async (...args: unknown[]) => { calls.push({ method: "resolveSessions", args }); return { ok: false, code: "not_found", error: "inert fixture" }; },
|
||||
send: async (...args: unknown[]) => { calls.push({ method: "send", args }); return { ok: false, error: "inert" }; },
|
||||
capture: async (...args: unknown[]) => { calls.push({ method: "capture", args }); return { ok: false, error: "inert" }; },
|
||||
broadcast: async (...args: unknown[]) => { calls.push({ method: "broadcast", args }); return { ok: false, error: "inert" }; },
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser boundary on the real route table", () => {
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let spy: http.Server;
|
||||
let spyHits = 0;
|
||||
const savedHome = process.env.OPENRIG_HOME;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createFullTestDb();
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "bb-routes-"));
|
||||
spyHits = 0;
|
||||
spy = http.createServer((_req, res) => { spyHits++; res.writeHead(200, { "content-type": "application/json" }); res.end("[]"); });
|
||||
await new Promise<void>((r) => spy.listen(0, "127.0.0.1", () => r()));
|
||||
const port = (spy.address() as AddressInfo).port;
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: peer\n transport: http\n url: http://127.0.0.1:${port}\n`);
|
||||
process.env.OPENRIG_HOME = home;
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = savedHome;
|
||||
await new Promise<void>((r) => spy.close(() => r()));
|
||||
db.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function build(extra: Record<string, unknown> = {}) {
|
||||
const calls: Call[] = [];
|
||||
const previews: unknown[] = [];
|
||||
const decisions: Array<{ outcome: string; code?: string }> = [];
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: inertTransport(calls) as never,
|
||||
queueRepo: new QueueRepository(db, new EventBus(db), { validateRig: () => true }),
|
||||
terminalService: { previewView: async (req: unknown) => { previews.push(req); return { ok: false, error: "inert" }; } } as never,
|
||||
browserBoundaryObserver: (d) => decisions.push(d),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
const rows = () => (db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
return { app, calls, previews, decisions, rows };
|
||||
}
|
||||
|
||||
const req = (app: { request: (p: string, i: RequestInit) => Response | Promise<Response> }, p: string, h: Record<string, string>, method = "GET", body?: unknown) =>
|
||||
app.request(p, { method, headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...h }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
|
||||
it("POST /api/transport/send: refused origin or target reaches no handler; admitted reaches it once", async () => {
|
||||
const b = build();
|
||||
const body = { session: "dev-impl@rig", text: "fixture" };
|
||||
for (const h of [
|
||||
{ Host: "evil.example:7433", Origin: "http://evil.example:7433" }, // rebinding shape
|
||||
{ Host: "127.0.0.1:7433", Origin: "http://evil.example" }, // cross-site
|
||||
{ Host: "127.0.0.1:7433", Origin: "null" },
|
||||
{ Host: "unknown.example:7433" }, // non-browser, unlisted name
|
||||
]) {
|
||||
const res = await req(b.app, "/api/transport/send", h, "POST", body);
|
||||
expect(res.status, JSON.stringify(h)).toBe(403);
|
||||
}
|
||||
expect(b.calls).toHaveLength(0);
|
||||
const ok = await req(b.app, "/api/transport/send", { Host: "127.0.0.1:7433" }, "POST", body);
|
||||
expect(ok.status).not.toBe(403);
|
||||
expect(b.calls.map((c) => c.method)).toEqual(["resolveSessions"]);
|
||||
expect(b.decisions).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("GET /api/terminal/preview: a no-Origin read addressed to an attacker name reaches no handler", async () => {
|
||||
const b = build();
|
||||
expect((await req(b.app, "/api/terminal/preview?view=x", { Host: "evil.example:7433" })).status).toBe(403);
|
||||
expect((await req(b.app, "/api/terminal/preview?view=x", { Host: "evil.example:7433", "Sec-Fetch-Site": "same-origin" })).status).toBe(403);
|
||||
expect(b.previews).toHaveLength(0);
|
||||
await req(b.app, "/api/terminal/preview?view=x", { Host: "localhost:7433" });
|
||||
expect(b.previews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("POST /api/queue/create: a refused request writes no row; an admitted one does", async () => {
|
||||
const b = build();
|
||||
const body = { sourceSession: "orch@rig-a", destinationSession: "dev@rig-a", body: "fixture" };
|
||||
const refused = await req(b.app, "/api/queue/create", { Host: "127.0.0.1:7433", Origin: "http://evil.example", "X-OpenRig-Session": "orch@rig-a" }, "POST", body);
|
||||
expect(refused.status).toBe(403);
|
||||
expect(b.rows()).toBe(0);
|
||||
const ok = await req(b.app, "/api/queue/create", { Host: "127.0.0.1:7433", "X-OpenRig-Session": "orch@rig-a" }, "POST", body);
|
||||
expect(ok.status).toBeLessThan(300);
|
||||
expect(b.rows()).toBe(1);
|
||||
});
|
||||
|
||||
it("GET /api/ps?host=peer: refused before the read-through forwards; admitted forwards once", async () => {
|
||||
const b = build();
|
||||
expect((await req(b.app, "/api/ps?host=peer", { Host: "127.0.0.1:7433", Origin: "http://evil.example" })).status).toBe(403);
|
||||
expect((await req(b.app, "/api/ps?host=peer", { Host: "evil.example:7433" })).status).toBe(403);
|
||||
expect(spyHits).toBe(0);
|
||||
const ok = await req(b.app, "/api/ps?host=peer", { Host: "127.0.0.1:7433" });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(spyHits).toBe(1);
|
||||
});
|
||||
|
||||
it("UI on admits the own UI origin through the real table; a token never waives an origin refusal", async () => {
|
||||
const b = build({ webUiEnabled: true, missionControlBearerToken: "tok-1" });
|
||||
expect((await req(b.app, "/api/ps", { Host: "localhost:7433", Origin: "http://localhost:7433" })).status).toBe(200);
|
||||
expect((await req(b.app, "/api/ps", { Host: "custom.example:7433", Authorization: "Bearer tok-1" })).status).toBe(200);
|
||||
const r = await req(b.app, "/api/ps", { Host: "custom.example:7433", Authorization: "Bearer tok-1", Origin: "http://evil.example" });
|
||||
expect(r.status).toBe(403);
|
||||
expect(((await r.json()) as { code: string }).code).toBe("browser_origin_refused");
|
||||
});
|
||||
|
||||
it("a waived target name keeps downstream route authorization: wrong-route token still refused by the route", async () => {
|
||||
const b = build({ missionControlBearerToken: "mc-token", terminalBearerToken: "term-token" });
|
||||
const body = { session: "dev-impl@rig", text: "fixture" };
|
||||
// Mission-control token waives the name check, but /api/transport still requires the terminal token.
|
||||
const r = await req(b.app, "/api/transport/send", { Host: "custom.example:7433", Authorization: "Bearer mc-token" }, "POST", body);
|
||||
expect(r.status).toBe(401);
|
||||
expect(b.calls).toHaveLength(0);
|
||||
const ok = await req(b.app, "/api/transport/send", { Host: "custom.example:7433", Authorization: "Bearer term-token" }, "POST", body);
|
||||
expect(ok.status).not.toBe(401);
|
||||
expect(b.calls.map((c) => c.method)).toEqual(["resolveSessions"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
browserBoundary,
|
||||
discoverTailscaleSelfNames,
|
||||
osOwnNames,
|
||||
parseHostHeader,
|
||||
parseOrigin,
|
||||
type BrowserBoundaryOptions,
|
||||
} from "../src/middleware/browser-boundary.js";
|
||||
|
||||
const SELF = "openrig-vm.taile8a08.ts.net";
|
||||
|
||||
function guarded(opts: Partial<BrowserBoundaryOptions> = {}) {
|
||||
const lines: string[] = [];
|
||||
const decisions: Array<{ outcome: string; code?: string }> = [];
|
||||
let hits = 0;
|
||||
const app = new Hono();
|
||||
app.use("/api/*", browserBoundary({
|
||||
webUiEnabled: false,
|
||||
bearerTokens: [],
|
||||
hostName: () => "Box-1.local",
|
||||
warn: (line) => lines.push(line),
|
||||
onDecision: (d) => decisions.push(d),
|
||||
...opts,
|
||||
}));
|
||||
app.all("/api/*", (c) => { hits++; return c.json({ ok: true }); });
|
||||
const send = async (h: { host?: string; origin?: string; auth?: string; method?: string; path?: string }) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (h.host !== undefined) headers["Host"] = h.host;
|
||||
if (h.origin !== undefined) headers["Origin"] = h.origin;
|
||||
if (h.auth !== undefined) headers["Authorization"] = h.auth;
|
||||
const res = await app.request(h.path ?? "/api/ps", { method: h.method ?? "GET", headers });
|
||||
return { status: res.status, body: await res.json() as { error?: string; code?: string } };
|
||||
};
|
||||
return { send, lines, decisions, hits: () => hits };
|
||||
}
|
||||
|
||||
describe("parsing and normalization", () => {
|
||||
it("parses Host: case, trailing dot, IPv6, default and explicit ports, malformed forms", () => {
|
||||
expect(parseHostHeader(undefined)).toEqual({ kind: "absent" });
|
||||
expect(parseHostHeader("LOCALHOST.:7433")).toEqual({ kind: "ok", hostname: "localhost", port: "7433" });
|
||||
expect(parseHostHeader("[::1]:7433")).toEqual({ kind: "ok", hostname: "::1", port: "7433" });
|
||||
expect(parseHostHeader("example.test")).toEqual({ kind: "ok", hostname: "example.test", port: "80" });
|
||||
for (const bad of ["", "a b", "x@y", "host/path", "[zz]:1", "h:99999", "h:0", "a,b", "a, b", "h:"]) {
|
||||
expect(parseHostHeader(bad)).toEqual({ kind: "malformed" });
|
||||
}
|
||||
});
|
||||
|
||||
it("parses Origin: null is opaque, paths/userinfo/lists are malformed, default ports resolve", () => {
|
||||
expect(parseOrigin("null")).toEqual({ kind: "opaque" });
|
||||
expect(parseOrigin("http://LOCALHOST.:7433")).toEqual({ kind: "ok", scheme: "http", hostname: "localhost", port: "7433" });
|
||||
expect(parseOrigin("http://localhost")).toEqual({ kind: "ok", scheme: "http", hostname: "localhost", port: "80" });
|
||||
expect(parseOrigin("https://x.test")).toEqual({ kind: "ok", scheme: "https", hostname: "x.test", port: "443" });
|
||||
for (const bad of ["not a url", "http://localhost:7433/x", "http://u:p@localhost", "http://a, http://b", "", "http://h?q=1"]) {
|
||||
expect(parseOrigin(bad)).toEqual({ kind: "malformed" });
|
||||
}
|
||||
});
|
||||
|
||||
it("derives OS own names: full, first label, and .local", () => {
|
||||
expect(osOwnNames(() => "Box-1.local")).toEqual(["box-1.local", "box-1"]);
|
||||
expect(osOwnNames(() => "studio")).toEqual(["studio", "studio.local"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("target-name rule", () => {
|
||||
it("accepts absent Host, loopback, IPv4/IPv6 literals and OS own names", async () => {
|
||||
const g = guarded();
|
||||
for (const host of [undefined, "localhost:7433", "LOCALHOST.:7433", "127.0.0.1:7433", "[::1]:7433",
|
||||
"100.106.37.120:7433", "[::ffff:127.0.0.1]:7433", "192.168.1.5", "box-1.local:7433", "box-1:7433", "BOX-1.LOCAL."]) {
|
||||
expect((await g.send({ host })).status, String(host)).toBe(200);
|
||||
}
|
||||
expect(g.hits()).toBe(11);
|
||||
});
|
||||
|
||||
it("refuses unknown, peer-suffix and malformed names with zero handler effects", async () => {
|
||||
const g = guarded({ discoverSelfNames: async () => [SELF] });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
for (const host of ["evil.example:7433", "other-vm.taile8a08.ts.net:7433", "taile8a08.ts.net", `${SELF}.evil.example`]) {
|
||||
const r = await g.send({ host });
|
||||
expect(r.status, host).toBe(403);
|
||||
expect(r.body.code).toBe("untrusted_host");
|
||||
expect(r.body.error).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
}
|
||||
for (const host of ["a b", "x@y", "h:99999", "a, b"]) {
|
||||
expect((await g.send({ host })).body.code, host).toBe("untrusted_host");
|
||||
}
|
||||
expect(g.hits()).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts the exact discovered self MagicDNS name and its short form only", async () => {
|
||||
const g = guarded({ discoverSelfNames: async () => [`${SELF}.`] });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(200);
|
||||
expect((await g.send({ host: "OPENRIG-VM:7433" })).status).toBe(200);
|
||||
expect((await g.send({ host: "other-vm:7433" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("OPENRIG_ALLOWED_HOSTS admits names; OPENRIG_ALLOWED_ORIGINS does not", async () => {
|
||||
expect((await guarded({ allowedHosts: "rig.example.com" }).send({ host: "rig.example.com:7433" })).status).toBe(200);
|
||||
expect((await guarded({ allowedOrigins: "rig.example.com,http://rig.example.com:7433" }).send({ host: "rig.example.com:7433" })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery controls", () => {
|
||||
it("unavailable, malformed and failing discovery leave loopback/IP working and the name refused", async () => {
|
||||
for (const discoverSelfNames of [
|
||||
async () => [] as string[],
|
||||
async () => ["bad name!", "", "a..b", "-x.ts.net"],
|
||||
async () => { throw new Error("ESERVFAIL"); },
|
||||
]) {
|
||||
const g = guarded({ discoverSelfNames, rediscoverAfterMs: 60_000 });
|
||||
expect((await g.send({ host: "127.0.0.1:7433" })).status).toBe(200);
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("a timed-out lookup is bounded and never delays loopback/IP requests", async () => {
|
||||
const g = guarded({ discoverSelfNames: () => new Promise<string[]>(() => {}), discoveryTimeoutMs: 50 });
|
||||
const t0 = Date.now();
|
||||
expect((await g.send({ host: "127.0.0.1:7433" })).status).toBe(200);
|
||||
expect(Date.now() - t0).toBeLessThan(40);
|
||||
const t1 = Date.now();
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(403);
|
||||
expect(Date.now() - t1).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("an unrecognized name triggers at most one bounded re-lookup per window, single-flight", async () => {
|
||||
let calls = 0;
|
||||
let names: string[] = [];
|
||||
let clock = 1_000;
|
||||
const g = guarded({ discoverSelfNames: async () => { calls++; return names; }, rediscoverAfterMs: 10_000, now: () => clock });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(calls).toBe(1);
|
||||
names = [SELF];
|
||||
expect((await g.send({ host: `${SELF}:7433` })).status).toBe(403); // inside the window: no re-lookup
|
||||
expect(calls).toBe(1);
|
||||
clock += 10_000;
|
||||
const results = await Promise.all([g.send({ host: `${SELF}:7433` }), g.send({ host: `${SELF}:7433` })]);
|
||||
expect(results.map((r) => r.status)).toEqual([200, 200]);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("discoverTailscaleSelfNames: no tailnet IP means no lookup; names are validated and short forms added", async () => {
|
||||
const reverse = vi.fn(async () => [`${SELF}.`, "bad name!"]);
|
||||
expect(await discoverTailscaleSelfNames({ timeoutMs: 10, tailscaleIp: () => null, reverse })).toEqual([]);
|
||||
expect(reverse).not.toHaveBeenCalled();
|
||||
expect(await discoverTailscaleSelfNames({ timeoutMs: 10, tailscaleIp: () => "100.106.37.120", reverse })).toEqual([SELF, "openrig-vm"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser-origin rule", () => {
|
||||
it("UI off trusts no default origin; UI on trusts only the exact own UI origin", async () => {
|
||||
const off = guarded();
|
||||
expect((await off.send({ host: "localhost:7433", origin: "http://localhost:7433" })).body.code).toBe("browser_origin_refused");
|
||||
const on = guarded({ webUiEnabled: true });
|
||||
expect((await on.send({ host: "localhost:7433", origin: "http://localhost:7433" })).status).toBe(200);
|
||||
expect((await on.send({ host: "127.0.0.1:7433", origin: "http://127.0.0.1:7433" })).status).toBe(200);
|
||||
expect((await on.send({ host: "localhost", origin: "http://localhost" })).status).toBe(200);
|
||||
expect((await on.send({ host: "localhost:7433", origin: "http://LOCALHOST.:7433" })).status).toBe(200);
|
||||
for (const [host, origin] of [
|
||||
["localhost:7433", "http://localhost:5173"], // other-port local app or dev server
|
||||
["127.0.0.1:7433", "http://localhost:7433"], // origin is not this request's target
|
||||
["localhost:7433", "https://localhost:7433"],
|
||||
["evil.example:7433", "http://evil.example:7433"], // rebinding shape
|
||||
["localhost:7433", "null"],
|
||||
["localhost:7433", "http://localhost:7433/path"],
|
||||
] as const) {
|
||||
const r = await on.send({ host, origin });
|
||||
expect(r.status, `${host} ${origin}`).toBe(403);
|
||||
}
|
||||
expect((await on.send({ origin: "http://localhost:7433" })).body.code).toBe("browser_origin_refused"); // no Host
|
||||
});
|
||||
|
||||
it("explicit origins keep main's formats and are independent of target-name allowances", async () => {
|
||||
const g = guarded({ allowedOrigins: "http://localhost:5173, tools.example, chrome-extension://abc" });
|
||||
expect((await g.send({ host: "localhost:7433", origin: "http://localhost:5173" })).status).toBe(200);
|
||||
expect((await g.send({ host: "127.0.0.1:7433", origin: "https://tools.example:8443" })).status).toBe(200);
|
||||
expect((await g.send({ host: "127.0.0.1:7433", origin: "chrome-extension://abc" })).status).toBe(200);
|
||||
expect((await g.send({ host: "localhost:7433", origin: "http://localhost:5174" })).status).toBe(403);
|
||||
const hostOnly = guarded({ allowedHosts: "rig.example.com", webUiEnabled: false });
|
||||
const r = await hostOnly.send({ host: "rig.example.com:7433", origin: "http://rig.example.com:7433" });
|
||||
expect(r.body.code).toBe("browser_origin_refused");
|
||||
});
|
||||
|
||||
it("refusal bodies follow {error, code} with a remedy and never echo allowance lists", async () => {
|
||||
const g = guarded({ allowedOrigins: "http://secret-allowed.example" });
|
||||
const r = await g.send({ host: "localhost:7433", origin: "http://evil.example" });
|
||||
expect(r.body.code).toBe("browser_origin_refused");
|
||||
expect(r.body.error).toContain("OPENRIG_ALLOWED_ORIGINS");
|
||||
expect(r.body.error).toContain("cannot read this response");
|
||||
expect(JSON.stringify(r.body)).not.toContain("secret-allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bearer token waiver", () => {
|
||||
it("a valid configured Authorization token waives only the target-name refusal", async () => {
|
||||
const g = guarded({ bearerTokens: [null, "tok-123"] });
|
||||
expect((await g.send({ host: "custom.example:7433", auth: "Bearer tok-123" })).status).toBe(200);
|
||||
expect((await g.send({ host: "custom.example:7433", auth: "Bearer wrong" })).status).toBe(403);
|
||||
expect((await g.send({ host: "custom.example:7433", path: "/api/ps?token=tok-123" })).status).toBe(403);
|
||||
expect((await g.send({ host: "a b", auth: "Bearer tok-123" })).status).toBe(403); // malformed is not waived
|
||||
const r = await g.send({ host: "custom.example:7433", auth: "Bearer tok-123", origin: "http://evil.example" });
|
||||
expect(r.body.code).toBe("browser_origin_refused");
|
||||
});
|
||||
|
||||
it("no configured token means no waiver", async () => {
|
||||
const g = guarded({ bearerTokens: [null, ""] });
|
||||
expect((await g.send({ host: "custom.example:7433", auth: "Bearer " })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one decision and bounded logging", () => {
|
||||
it("records one decision per request and logs distinct refusals up to 20, then one suppression line", async () => {
|
||||
const g = guarded();
|
||||
for (let i = 0; i < 100; i++) await g.send({ host: "same.example" });
|
||||
expect(g.lines).toHaveLength(1);
|
||||
for (let i = 0; i < 30; i++) await g.send({ host: `n${i}.example`, path: "/api/ps?token=SECRET" });
|
||||
expect(g.lines).toHaveLength(21);
|
||||
expect(g.lines.at(-1)).toContain("not logged");
|
||||
expect(g.lines.join("\n")).not.toContain("SECRET");
|
||||
expect(g.decisions).toHaveLength(130);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// #261: recognized built-in startup files re-anchor to the running install; nothing else moves.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, runningBuiltinAssetsRoot, runningShippedSpecsRoot } from "../src/domain/builtin-startup-files.js";
|
||||
|
||||
const RUNNING = "/new/lib/node_modules/@openrig/cli/daemon/assets";
|
||||
const OLD = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/assets";
|
||||
const meta = { deliveryHint: "guidance_merge" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const] };
|
||||
|
||||
describe("reanchorBuiltinStartupFile", () => {
|
||||
it.each([
|
||||
["CULTURE-default.md", "guidance/CULTURE-default.md"],
|
||||
["openrig-start.md", "guidance/openrig-start.md"],
|
||||
["openrig-onboarding-01.md", "onboarding/01-world-and-purpose.md"],
|
||||
["openrig-onboarding-02.md", "onboarding/02-self-and-competent-action.md"],
|
||||
])("re-anchors %s from an old packaged install, preserving metadata", (name, rel) => {
|
||||
const stored = { path: name, absolutePath: `${OLD}/${rel}`, ownerRoot: OLD, ...meta, kind: "file" as const };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING)).toEqual({ ...stored, absolutePath: `${RUNNING}/${rel}`, ownerRoot: RUNNING });
|
||||
});
|
||||
|
||||
it("re-anchors a built-in stored from a dev checkout (packages/daemon/assets)", () => {
|
||||
const dev = "/src/openrig/packages/daemon/assets";
|
||||
const stored = { path: "openrig-start.md", absolutePath: `${dev}/guidance/openrig-start.md`, ownerRoot: dev, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING).absolutePath).toBe(`${RUNNING}/guidance/openrig-start.md`);
|
||||
});
|
||||
|
||||
it("leaves a custom rig file with the same basename unchanged", () => {
|
||||
const custom = { path: "CULTURE-default.md", absolutePath: "/home/u/rig/CULTURE-default.md", ownerRoot: "/home/u/rig", ...meta };
|
||||
expect(reanchorBuiltinStartupFile(custom, RUNNING)).toBe(custom);
|
||||
});
|
||||
|
||||
it("leaves a built-in name whose stored path is not the known relative path unchanged", () => {
|
||||
const odd = { path: "CULTURE-default.md", absolutePath: `${OLD}/custom/CULTURE-default.md`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(odd, RUNNING)).toBe(odd);
|
||||
});
|
||||
|
||||
it("leaves a known relative path under a non daemon/assets root unchanged", () => {
|
||||
const root = "/home/u/my-assets";
|
||||
const user = { path: "CULTURE-default.md", absolutePath: `${root}/guidance/CULTURE-default.md`, ownerRoot: root, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(user, RUNNING)).toBe(user);
|
||||
});
|
||||
|
||||
it("leaves non-built-in names unchanged even under daemon/assets", () => {
|
||||
const other = { path: "guidance/CULTURE-default.md", absolutePath: `${OLD}/guidance/CULTURE-default.md`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(other, RUNNING)).toBe(other);
|
||||
});
|
||||
|
||||
it.each(["constructor", "toString", "__proto__", "hasOwnProperty"])("a custom startup file named %s is returned unchanged (no inherited-key match)", (name) => {
|
||||
const custom = { path: name, absolutePath: `/home/u/rig/${name}`, ownerRoot: "/home/u/rig", ...meta };
|
||||
expect(reanchorBuiltinStartupFile(custom, RUNNING)).toBe(custom);
|
||||
const underAssets = { path: name, absolutePath: `${OLD}/${name}`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(underAssets, RUNNING)).toBe(underAssets);
|
||||
});
|
||||
|
||||
it("defaults to this daemon's assets root, which holds all four built-ins", () => {
|
||||
expect(runningBuiltinAssetsRoot()).toBe(path.resolve(import.meta.dirname, "../assets"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reanchorShippedProjectionEntry", () => {
|
||||
const RUN_SPECS = "/new/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const OLD_SPECS = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const base = { category: "runtime_resource", effectiveId: "shared:claude-default-settings", sourceSpec: "shared",
|
||||
resourcePath: "runtime/claude-settings.fragment.json", resourceType: "claude_settings_fragment", mergeStrategy: "managed_block", target: ".claude/settings.local.json" };
|
||||
|
||||
it("re-anchors a shipped resource (source and resource under an old install's daemon/specs), preserving every other field", () => {
|
||||
const stored = { ...base, sourcePath: `${OLD_SPECS}/agents/shared`, absolutePath: `${OLD_SPECS}/agents/shared/runtime/claude-settings.fragment.json` };
|
||||
expect(reanchorShippedProjectionEntry(stored, RUN_SPECS)).toEqual({
|
||||
...base, sourcePath: `${RUN_SPECS}/agents/shared`, absolutePath: `${RUN_SPECS}/agents/shared/runtime/claude-settings.fragment.json`,
|
||||
});
|
||||
});
|
||||
|
||||
it("re-anchors kernel agent guidance stored under the rig's own shipped spec directory", () => {
|
||||
const dir = `${OLD_SPECS}/rigs/launch/kernel/agents/advisor/lead`;
|
||||
const stored = { ...base, category: "guidance", effectiveId: "role", sourceSpec: "advisor.lead", sourcePath: dir, absolutePath: `${dir}/guidance/role.md` };
|
||||
expect(reanchorShippedProjectionEntry(stored, RUN_SPECS).absolutePath).toBe(`${RUN_SPECS}/rigs/launch/kernel/agents/advisor/lead/guidance/role.md`);
|
||||
});
|
||||
|
||||
it("re-anchors from a dev checkout (packages/daemon/specs)", () => {
|
||||
const dev = "/src/openrig/packages/daemon/specs";
|
||||
const stored = { ...base, sourcePath: `${dev}/agents/shared`, absolutePath: `${dev}/agents/shared/runtime/claude-mcp.fragment.json` };
|
||||
expect(reanchorShippedProjectionEntry(stored, RUN_SPECS).absolutePath).toBe(`${RUN_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`);
|
||||
});
|
||||
|
||||
it("leaves a plugin projected from ~/.openrig/plugins unchanged even though its sourcePath is a shipped spec", () => {
|
||||
const plugin = { ...base, category: "plugin", effectiveId: "shared:openrig-core", sourcePath: `${OLD_SPECS}/agents/shared`, absolutePath: "/home/u/.openrig/plugins/openrig-core" };
|
||||
expect(reanchorShippedProjectionEntry(plugin, RUN_SPECS)).toBe(plugin);
|
||||
});
|
||||
|
||||
it("leaves user spec resources unchanged", () => {
|
||||
const user = { ...base, sourcePath: "/home/u/rigs/acme/agents/dev", absolutePath: "/home/u/rigs/acme/agents/dev/guidance/role.md" };
|
||||
expect(reanchorShippedProjectionEntry(user, RUN_SPECS)).toBe(user);
|
||||
});
|
||||
|
||||
it("does not treat a user folder merely named daemon/specs as an OpenRig install", () => {
|
||||
const lookalike = { ...base, sourcePath: "/home/u/daemon/specs/agents/x", absolutePath: "/home/u/daemon/specs/agents/x/role.md" };
|
||||
expect(reanchorShippedProjectionEntry(lookalike, RUN_SPECS)).toBe(lookalike);
|
||||
});
|
||||
|
||||
it("defaults to this daemon's shipped specs root", () => {
|
||||
expect(runningShippedSpecsRoot()).toBe(path.resolve(import.meta.dirname, "../specs"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reanchorBuiltinStartupFile — shipped-spec startup files", () => {
|
||||
const RUN_SPECS = "/new/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const OLD_SPECS = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const KERNEL = "rigs/launch/kernel";
|
||||
|
||||
it("re-anchors the kernel rig culture (pre-launch guidance_merge), preserving metadata", () => {
|
||||
const stored = { path: "culture/CULTURE.md", absolutePath: `${OLD_SPECS}/${KERNEL}/culture/CULTURE.md`, ownerRoot: `${OLD_SPECS}/${KERNEL}`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS)).toEqual({
|
||||
...stored, absolutePath: `${RUN_SPECS}/${KERNEL}/culture/CULTURE.md`, ownerRoot: `${RUN_SPECS}/${KERNEL}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("re-anchors agent role and startup context (post-launch send_text)", () => {
|
||||
const agent = `${OLD_SPECS}/${KERNEL}/agents/advisor/lead`;
|
||||
for (const rel of ["guidance/role.md", "startup/context.md"]) {
|
||||
const stored = { path: rel, absolutePath: `${agent}/${rel}`, ownerRoot: agent, deliveryHint: "send_text" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const] };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS)).toMatchObject({
|
||||
absolutePath: `${RUN_SPECS}/${KERNEL}/agents/advisor/lead/${rel}`, ownerRoot: `${RUN_SPECS}/${KERNEL}/agents/advisor/lead`, deliveryHint: "send_text",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a file outside the shipped specs root unchanged even when its ownerRoot is shipped", () => {
|
||||
const stored = { path: "notes.md", absolutePath: "/home/u/notes.md", ownerRoot: `${OLD_SPECS}/${KERNEL}`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS)).toBe(stored);
|
||||
});
|
||||
|
||||
it("leaves user rig culture and look-alike daemon/specs folders unchanged", () => {
|
||||
const user = { path: "culture/CULTURE.md", absolutePath: "/home/u/rigs/acme/culture/CULTURE.md", ownerRoot: "/home/u/rigs/acme", ...meta };
|
||||
const lookalike = { path: "culture/CULTURE.md", absolutePath: "/home/u/daemon/specs/r/culture/CULTURE.md", ownerRoot: "/home/u/daemon/specs/r", ...meta };
|
||||
expect(reanchorBuiltinStartupFile(user, RUNNING, RUN_SPECS)).toBe(user);
|
||||
expect(reanchorBuiltinStartupFile(lookalike, RUNNING, RUN_SPECS)).toBe(lookalike);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dev-checkout layouts re-anchor only when the stored file is missing", () => {
|
||||
const RUN_SPECS = "/new/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const DEV = "/src/openrig/packages/daemon";
|
||||
const present = () => true;
|
||||
const missing = () => false;
|
||||
|
||||
it("built-in from a present dev checkout stays as stored; a missing one re-anchors", () => {
|
||||
const stored = { path: "openrig-start.md", absolutePath: `${DEV}/assets/guidance/openrig-start.md`, ownerRoot: `${DEV}/assets`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, present)).toBe(stored);
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, missing).absolutePath).toBe(`${RUNNING}/guidance/openrig-start.md`);
|
||||
});
|
||||
|
||||
it("shipped-spec startup file from a present dev checkout stays as stored; a missing one re-anchors", () => {
|
||||
const stored = { path: "culture/CULTURE.md", absolutePath: `${DEV}/specs/rigs/launch/kernel/culture/CULTURE.md`, ownerRoot: `${DEV}/specs/rigs/launch/kernel`, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, present)).toBe(stored);
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, missing).absolutePath).toBe(`${RUN_SPECS}/rigs/launch/kernel/culture/CULTURE.md`);
|
||||
});
|
||||
|
||||
it("projection resource from a present dev checkout stays as stored; a missing one re-anchors", () => {
|
||||
const entry = { sourcePath: `${DEV}/specs/agents/shared`, absolutePath: `${DEV}/specs/agents/shared/runtime/claude-mcp.fragment.json`, effectiveId: "shared:claude-default-mcp" };
|
||||
expect(reanchorShippedProjectionEntry(entry, RUN_SPECS, present)).toBe(entry);
|
||||
expect(reanchorShippedProjectionEntry(entry, RUN_SPECS, missing).absolutePath).toBe(`${RUN_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`);
|
||||
});
|
||||
|
||||
it("packaged installs re-anchor even while the stored file is present", () => {
|
||||
const stored = { path: "CULTURE-default.md", absolutePath: `${OLD}/guidance/CULTURE-default.md`, ownerRoot: OLD, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, present).absolutePath).toBe(`${RUNNING}/guidance/CULTURE-default.md`);
|
||||
});
|
||||
|
||||
it("an unrecognized daemon/assets layout is not treated as an OpenRig install", () => {
|
||||
const other = "/opt/something/daemon/assets";
|
||||
const stored = { path: "CULTURE-default.md", absolutePath: `${other}/guidance/CULTURE-default.md`, ownerRoot: other, ...meta };
|
||||
expect(reanchorBuiltinStartupFile(stored, RUNNING, RUN_SPECS, missing)).toBe(stored);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy persisted shapes are returned exactly as stored", () => {
|
||||
it("startup file without ownerRoot (or path) does not throw and is unchanged", () => {
|
||||
const noOwner = { path: "required-onboarding.md", absolutePath: "/tmp/never-read/required-onboarding.md", required: true } as unknown as { path: string; absolutePath: string; ownerRoot: string };
|
||||
expect(reanchorBuiltinStartupFile(noOwner, RUNNING)).toBe(noOwner);
|
||||
const noPath = { absolutePath: `${OLD}/guidance/CULTURE-default.md`, ownerRoot: OLD } as unknown as { path: string; absolutePath: string; ownerRoot: string };
|
||||
expect(() => reanchorBuiltinStartupFile(noPath, RUNNING)).not.toThrow();
|
||||
});
|
||||
|
||||
it("projection entry without sourcePath does not throw and is unchanged", () => {
|
||||
const legacy = { absolutePath: "/tmp/never-read/CLAUDE.md", category: "memory" } as unknown as { sourcePath: string; absolutePath: string };
|
||||
expect(reanchorShippedProjectionEntry(legacy)).toBe(legacy);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { createTestApp } from "./helpers/test-app.js";
|
||||
import { unpack } from "../src/domain/bundle-archive.js";
|
||||
import { materializePodBundle } from "../src/domain/bundle-source-resolver.js";
|
||||
|
||||
// A pod bundle copies agent packages and declared rig files verbatim. Every byte must survive
|
||||
// create -> archive -> install, whether or not the file is UTF-8 text.
|
||||
|
||||
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
|
||||
|
||||
function pseudoRandom(n: number, seed: number): Buffer {
|
||||
const out = Buffer.alloc(n);
|
||||
let x = seed >>> 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x = (Math.imul(x, 1664525) + 1013904223) >>> 0;
|
||||
out[i] = x >>> 24;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PAYLOADS: Record<string, Buffer> = {
|
||||
"agents/impl/assets/random.bin": pseudoRandom(4096, 7),
|
||||
"agents/impl/assets/logo.png": Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), Buffer.from(Array.from({ length: 128 }, (_, i) => 128 + i))]),
|
||||
"agents/impl/assets/nul.dat": Buffer.from([0x61, 0x00, 0x62, 0x00, 0x00, 0x63]),
|
||||
"agents/impl/assets/invalid-utf8.txt": Buffer.from([0x6f, 0x6b, 0x20, 0xc3, 0x28, 0x20, 0xa0, 0xa1, 0x20, 0xff]),
|
||||
"agents/impl/assets/empty.txt": Buffer.alloc(0),
|
||||
"agents/impl/skills/greet/SKILL.md": Buffer.from("Greet ü 漢 😀\r\nCRLF line\r\nno trailing newline", "utf8"),
|
||||
"startup/blob.bin": Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x80, 0x00, 0xc0]),
|
||||
};
|
||||
|
||||
const AGENT_YAML = [
|
||||
'name: impl',
|
||||
'version: "1.0.0"',
|
||||
"resources:",
|
||||
" skills:",
|
||||
" - id: greet",
|
||||
" path: skills/greet",
|
||||
"profiles:",
|
||||
" default:",
|
||||
" uses:",
|
||||
" skills: [greet]",
|
||||
].join("\n");
|
||||
|
||||
const RIG_YAML = [
|
||||
'version: "0.2"',
|
||||
"name: bytes-rig",
|
||||
"pods:",
|
||||
" - id: dev",
|
||||
" label: Dev",
|
||||
" members:",
|
||||
" - id: impl",
|
||||
' agent_ref: "local:agents/impl"',
|
||||
" profile: default",
|
||||
" runtime: claude-code",
|
||||
" cwd: .",
|
||||
" startup:",
|
||||
" files:",
|
||||
" - path: startup/blob.bin",
|
||||
" actions: []",
|
||||
" edges: []",
|
||||
"edges: []",
|
||||
].join("\n");
|
||||
|
||||
describe("pod bundle byte preservation (create -> archive -> install)", () => {
|
||||
let db: Database.Database;
|
||||
let app: ReturnType<typeof createTestApp>["app"];
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-bytes-"));
|
||||
app = createTestApp(db).app;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps binary, non-UTF-8, NUL, empty and multibyte files byte-identical, with truthful integrity", async () => {
|
||||
const src = path.join(tmpDir, "src");
|
||||
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
|
||||
fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, rel), bytes);
|
||||
}
|
||||
fs.writeFileSync(path.join(src, "agents/impl/agent.yaml"), AGENT_YAML);
|
||||
fs.writeFileSync(path.join(src, "rig.yaml"), RIG_YAML);
|
||||
const sourceBefore = Object.fromEntries(Object.keys(PAYLOADS).map((rel) => [rel, sha(fs.readFileSync(path.join(src, rel)))]));
|
||||
|
||||
const outputPath = path.join(tmpDir, "bytes.rigbundle");
|
||||
const created = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath: path.join(src, "rig.yaml"), rigRoot: src, bundleName: "bytes", bundleVersion: "0.1.0", outputPath }),
|
||||
});
|
||||
expect(created.status, await created.clone().text()).toBe(201);
|
||||
expect((await created.json()).schemaVersion).toBe(2);
|
||||
|
||||
// Source assets stay untouched.
|
||||
for (const rel of Object.keys(PAYLOADS)) {
|
||||
expect(sha(fs.readFileSync(path.join(src, rel))), rel).toBe(sourceBefore[rel]);
|
||||
}
|
||||
|
||||
// Archive: every copied file equals its source, and the manifest hashes the source bytes.
|
||||
const extracted = path.join(tmpDir, "extracted");
|
||||
fs.mkdirSync(extracted);
|
||||
await unpack(outputPath, extracted);
|
||||
const manifest = fs.readFileSync(path.join(extracted, "bundle.yaml"), "utf8");
|
||||
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
|
||||
const archived = fs.readFileSync(path.join(extracted, rel));
|
||||
expect(archived.equals(bytes), `${rel} in archive`).toBe(true);
|
||||
expect(manifest, `${rel} integrity hash`).toContain(`${rel}: ${sha(bytes)}`);
|
||||
}
|
||||
|
||||
const inspected = await app.request("/api/bundles/inspect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath: outputPath }),
|
||||
});
|
||||
const view = await inspected.json();
|
||||
expect(view.digestValid).toBe(true);
|
||||
expect(view.integrityResult.passed).toBe(true);
|
||||
|
||||
// Install: the materialized target holds the source bytes.
|
||||
const target = path.join(tmpDir, "target");
|
||||
fs.mkdirSync(target);
|
||||
expect(materializePodBundle(extracted, target)).toEqual({ ok: true });
|
||||
for (const [rel, bytes] of Object.entries(PAYLOADS)) {
|
||||
expect(fs.readFileSync(path.join(target, rel)).equals(bytes), `${rel} installed`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import nodePath from "node:path";
|
||||
import realFs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { routeSkills, type SkillsRouterFsOps, type RouteSkillsInput } from "../src/domain/bundle-skills-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.2: bundle-skills-router pure-function tests.
|
||||
@@ -11,12 +13,11 @@ function mockFs(initialFiles: Record<string, string> = {}): SkillsRouterFsOps &
|
||||
_written: written,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
exists: (p: string) => written.has(p),
|
||||
readFile: (p: string) => {
|
||||
const v = written.get(p);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
|
||||
return v;
|
||||
copyFile: (src: string, dest: string) => {
|
||||
const v = written.get(src);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${src}`);
|
||||
written.set(dest, v);
|
||||
},
|
||||
writeFile: (p: string, c: string) => { written.set(p, c); },
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); },
|
||||
};
|
||||
}
|
||||
@@ -173,3 +174,25 @@ describe("routeSkills", () => {
|
||||
expect(fs._written.get(`${packageRoot}/test-pkg/skills/DUAL.md`)).toBe("dual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeSkills copies bytes through the real filesystem", () => {
|
||||
it("a non-UTF-8 file arrives byte-identical", () => {
|
||||
const root = realFs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-skills-router-bytes-"));
|
||||
try {
|
||||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff, 0xfe]);
|
||||
const bundleRoot = nodePath.join(root, "bundle");
|
||||
realFs.mkdirSync(nodePath.join(bundleRoot, "skills/blob"), { recursive: true });
|
||||
realFs.writeFileSync(nodePath.join(bundleRoot, "skills/blob", "logo.png"), bytes);
|
||||
const target = nodePath.join(root, "target");
|
||||
const result = routeSkills(
|
||||
{ ...makeInput(), bundleRoot, declaredSkills: ["skills/blob/logo.png"], targetSkillsDir: target },
|
||||
{ exists: realFs.existsSync, mkdirp: (p) => realFs.mkdirSync(p, { recursive: true }), copyFile: realFs.copyFileSync },
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
const installedAt = result.records[0]!.installedAt!;
|
||||
expect(realFs.readFileSync(installedAt).equals(bytes)).toBe(true);
|
||||
} finally {
|
||||
realFs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import nodePath from "node:path";
|
||||
import realFs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { routeWorkflowSpecs, type WorkflowSpecsRouterFsOps, type RouteWorkflowSpecsInput } from "../src/domain/bundle-workflow-specs-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.3e step 2: bundle-workflow-specs-router
|
||||
@@ -13,12 +15,11 @@ function mockFs(initialFiles: Record<string, string> = {}): WorkflowSpecsRouterF
|
||||
_written: written,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
exists: (p: string) => written.has(p),
|
||||
readFile: (p: string) => {
|
||||
const v = written.get(p);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
|
||||
return v;
|
||||
copyFile: (src: string, dest: string) => {
|
||||
const v = written.get(src);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${src}`);
|
||||
written.set(dest, v);
|
||||
},
|
||||
writeFile: (p: string, c: string) => { written.set(p, c); },
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); },
|
||||
};
|
||||
}
|
||||
@@ -230,3 +231,25 @@ describe("routeWorkflowSpecs", () => {
|
||||
expect(r2.records[0]!.status).toBe("routed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeWorkflowSpecs copies bytes through the real filesystem", () => {
|
||||
it("a non-UTF-8 file arrives byte-identical", () => {
|
||||
const root = realFs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-workflow-specs-router-bytes-"));
|
||||
try {
|
||||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff, 0xfe]);
|
||||
const bundleRoot = nodePath.join(root, "bundle");
|
||||
realFs.mkdirSync(nodePath.join(bundleRoot, "workflows"), { recursive: true });
|
||||
realFs.writeFileSync(nodePath.join(bundleRoot, "workflows", "latin1.yaml"), bytes);
|
||||
const target = nodePath.join(root, "target");
|
||||
const result = routeWorkflowSpecs(
|
||||
{ ...makeInput(), bundleRoot, declaredWorkflowSpecs: ["workflows/latin1.yaml"], targetWorkflowSpecsDir: target },
|
||||
{ exists: realFs.existsSync, mkdirp: (p) => realFs.mkdirSync(p, { recursive: true }), copyFile: realFs.copyFileSync },
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
const installedAt = result.records[0]!.installedAt!;
|
||||
expect(realFs.readFileSync(installedAt).equals(bytes)).toBe(true);
|
||||
} finally {
|
||||
realFs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
|
||||
const token = "00000000-0000-4000-8000-000000000006";
|
||||
const autoScreen = "Restored conversation\n─────────\n❯\u00a0\n─────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n";
|
||||
const payload = "RC6 harmless startup content";
|
||||
const startedAt = "Thu Oct 1 05:53:16 2026";
|
||||
|
||||
describe("auto-mode startup content requires the launched Claude identity", () => {
|
||||
const dbs: ReturnType<typeof createFullTestDb>[] = [];
|
||||
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
|
||||
|
||||
it.each(["exact", "exit-zsh", "exit-bash", "missing", "wrong-token", "ambiguous", "background", "replaced-pane", "replaced-process", "unavailable", "plain-shell", "resume-missing", "native", "native-resume", "selected-custom", "selected-changed", "selected-wrong-named", "selected-wrong-conventional", "selected-title"])("startup: %s", async (mode) => {
|
||||
const isNative = mode.startsWith("native") || mode.startsWith("selected-");
|
||||
const good = ["exact", "native", "native-resume", "selected-custom", "selected-title"].includes(mode);
|
||||
const resumes = mode === "resume-missing" || mode === "native-resume";
|
||||
const executable = mode === "selected-title" ? "claude" : ["selected-wrong-named", "selected-wrong-conventional"].includes(mode) ? "/other/claude" : mode.startsWith("selected-") ? "/fixture/custom/2.1.285" : "/fixture/.local/share/claude/versions/2.1.285";
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
|
||||
const rig = rigRepo.createRig("auto-startup"), node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code" });
|
||||
const name = "test-c@auto-startup", session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
|
||||
const calls: { kind: string; text?: string; keys?: string[] }[] = [];
|
||||
let processReads = 0;
|
||||
const listProcesses = vi.fn(async () => {
|
||||
processReads++;
|
||||
if (mode === "unavailable") throw new Error("fixture observation unavailable");
|
||||
const native = { pid: 102, ppid: 101, pgid: mode === "background" ? 999 : 101, tpgid: 101,
|
||||
executableName: ["selected-title", "selected-wrong-named", "selected-wrong-conventional"].includes(mode) ? "claude" : isNative ? "2.1.285" : "claude", startedAt: mode === "replaced-process" && processReads > 1 ? "changed" : startedAt,
|
||||
command: `${isNative ? executable : "/opt/claude.exe"} --permission-mode auto ${resumes ? "--resume" : "--session-id"} ${mode === "wrong-token" ? "other-token" : token} --name ${name}` };
|
||||
return [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh fixture-launch.txt", startedAt },
|
||||
...(["exit-zsh", "exit-bash", "missing", "plain-shell", "resume-missing"].includes(mode) ? [] : [native]),
|
||||
...(mode === "ambiguous" ? [{ ...native, pid: 103 }] : []),
|
||||
];
|
||||
});
|
||||
const tmux = {
|
||||
hasSession: vi.fn(async () => true),
|
||||
getPaneCommand: vi.fn(async () => mode === "exit-zsh" ? "zsh" : mode === "exit-bash" ? "bash" : "sh"),
|
||||
capturePaneContent: vi.fn(async () => mode === "plain-shell" ? "admin@host ~ % " : autoScreen + (mode.startsWith("exit-") ? "Resume this session with:\nclaude --resume old\nadmin@host ~ % " : "")),
|
||||
getPanePid: vi.fn(async () => 100),
|
||||
listPanes: vi.fn(async () => [{ id: mode === "replaced-pane" && processReads > 0 ? "%2" : "%1" }]),
|
||||
sendShellCommand: vi.fn(async (_target: string, text: string) => { calls.push({ kind: "launch", text }); return { ok: true as const }; }),
|
||||
sendText: vi.fn(async (_target: string, text: string) => { calls.push({ kind: "content", text }); return { ok: true as const }; }),
|
||||
sendKeys: vi.fn(async (_target: string, keys: string[]) => { calls.push({ kind: "submit", keys }); return { ok: true as const }; }),
|
||||
} as unknown as TmuxAdapter;
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {}, sessionIdFactory: () => token,
|
||||
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`, assertCurrent: () => {}, configDir: "/fixture", ...(isNative ? { executable: mode === "selected-changed" ? "/fixture/custom/2.1.286" : ["selected-wrong-named", "selected-title"].includes(mode) ? "/fixture/custom/2.1.285" : mode === "selected-wrong-conventional" ? "/opt/a/claude" : executable } : {}) }) } as unknown as ClaudeManagedLaunch,
|
||||
fsOps: { exists: () => false, readFile: () => payload, writeFile: () => {}, mkdirp: () => {}, copyFile: () => {} },
|
||||
});
|
||||
const binding: NodeBinding = { id: "binding", nodeId: node.id, tmuxSession: name, tmuxPane: "%1", tmuxWindow: null,
|
||||
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/fixture", permissionMode: "auto" };
|
||||
const result = await new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux }).startNode({
|
||||
rigId: rig.id, nodeId: node.id, sessionId: session.id, binding, adapter,
|
||||
plan: { runtime: "claude-code", cwd: "/fixture", entries: [], startup: { files: [], actions: [] }, conflicts: [], noOps: [] } as never,
|
||||
resolvedStartupFiles: [{ path: "startup.txt", absolutePath: "/fixture/startup.txt", ownerRoot: "/fixture", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }],
|
||||
startupActions: [], isRestore: false, sessionName: name, readinessTimeoutMs: 1,
|
||||
...(resumes ? { resumeToken: token, resumeType: "claude_id", allowFreshFallback: true } : {}),
|
||||
});
|
||||
// Launch authorization is distinct from permission to paste/submit startup content.
|
||||
expect(calls.filter(c => c.kind === "launch")).toEqual([{ kind: "launch", text: `claude --permission-mode auto ${resumes ? "--resume" : "--session-id"} ${token} --name ${name}` }]);
|
||||
if (mode === "resume-missing") expect(result.startupStatus).toBe("attention_required");
|
||||
expect({ ok: result.ok, content: calls.filter(c => c.kind === "content"), submit: calls.filter(c => c.kind === "submit") }).toEqual({
|
||||
ok: good, content: good ? [{ kind: "content", text: payload }] : [], submit: good ? [{ kind: "submit", keys: ["C-m"] }] : [],
|
||||
});
|
||||
// Orchestrator checks readiness before content and once more at completion.
|
||||
if (good && !resumes) expect(listProcesses).toHaveBeenCalledTimes(4);
|
||||
if (mode === "plain-shell") expect(listProcesses).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
|
||||
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
|
||||
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
|
||||
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
// Shape of Fleet's Claude 2.1.220 auto-mode screen after full down/up:
|
||||
// the header has scrolled out; the empty prompt and mode footer remain.
|
||||
const autoScreen = "Restored conversation\n────────────────\n❯\u00a0\n────────────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor\n ● high · /effort\n";
|
||||
const token = "00000000-0000-4000-8000-000000000006";
|
||||
|
||||
describe("managed Claude full down/up", () => {
|
||||
const dbs: ReturnType<typeof createFullTestDb>[] = [];
|
||||
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
|
||||
|
||||
it.each(["exact", "native", "bare-shell", "wrong-token", "unobserved-pane-process", "ambiguous"])(
|
||||
"keeps identity proof separate from ordinary delivery: %s", async (mode) => {
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
const snapshotRepo = new SnapshotRepository(db);
|
||||
const checkpointStore = new CheckpointStore(db);
|
||||
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
|
||||
const rig = rigRepo.createRig("restore-test");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
|
||||
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: "restore-pod" });
|
||||
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained Fleet posture");
|
||||
const name = "test-c@restore-test";
|
||||
const old = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(old.id, "running");
|
||||
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
|
||||
let live = true;
|
||||
const tmux = {
|
||||
hasSession: vi.fn(async () => live),
|
||||
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
|
||||
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
|
||||
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
|
||||
listSessions: vi.fn(async () => live ? [{ name }] : []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => (mode === "ambiguous" ? ["%new", "%other"] : ["%new"]).map(id => ({ id, index: 0, cwd: "/", width: 80, height: 24, active: true }))),
|
||||
getPanePid: vi.fn(async () => mode === "unobserved-pane-process" ? 999 : 100),
|
||||
getPaneCommand: vi.fn(async () => mode === "bare-shell" ? "bash" : "sh"),
|
||||
capturePaneContent: vi.fn(async () => autoScreen),
|
||||
sendText: vi.fn(async () => ({ ok: true })),
|
||||
sendShellCommand: vi.fn(async () => ({ ok: true })),
|
||||
sendKeys: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as TmuxAdapter;
|
||||
const startedAt = "Thu Oct 1 05:53:16 2026";
|
||||
const listProcesses = async () => [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: mode === "bare-shell" ? 100 : 101, executableName: "bash", command: "-bash", startedAt },
|
||||
...(mode === "bare-shell" ? [] : [{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /tmp/fixture-launch.txt", startedAt }]),
|
||||
...(mode === "bare-shell" ? [] : [{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: mode === "native" ? "2.1.285" : "claude", command: `${mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe"} --permission-mode auto --resume ${mode === "wrong-token" ? "different" : token} --name ${name}`, startedAt }]),
|
||||
];
|
||||
// Real teardown captures the running occupant, exits the old row and clears bindings.
|
||||
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
|
||||
expect(down.errors).toEqual([]);
|
||||
expect(down.sessionsKilled).toBe(1);
|
||||
expect(snapshotRepo.getSnapshot(down.snapshotId!)?.kind).toBe("auto-pre-down");
|
||||
expect(sessionRegistry.getBindingForNode(node.id)).toBeNull();
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {},
|
||||
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`, assertCurrent: () => {}, configDir: "/fixture", executable: mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch,
|
||||
fsOps: {
|
||||
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {},
|
||||
} });
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
|
||||
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux),
|
||||
codexResume: new CodexResumeAdapter(tmux), listProcesses });
|
||||
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
|
||||
expect(up.ok).toBe(true);
|
||||
if (!up.ok) throw new Error(up.message);
|
||||
const latest = db.prepare("SELECT id, status, startup_status, resume_type, resume_token, resume_provenance FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(node.id);
|
||||
// Preserve the failed-state observation in a red run, before any later scrape.
|
||||
expect({ outcome: up.result.nodes[0], latest }).toMatchObject({
|
||||
// Without #264 the RC's joined restore verdict remains attention-only.
|
||||
// Native metadata and delivery after the next identity sweep are separate assertions.
|
||||
outcome: { status: "attention_required" },
|
||||
latest: (mode === "exact" || mode === "native")
|
||||
? { status: "running", startup_status: "ready", resume_type: "claude_id", resume_token: token, resume_provenance: "scrape" }
|
||||
: { status: "running", startup_status: "attention_required", resume_token: token, resume_provenance: null },
|
||||
});
|
||||
expect(latest).not.toMatchObject({ id: old.id });
|
||||
expect(tmux.sendShellCommand).toHaveBeenCalledExactlyOnceWith(name, `claude --permission-mode auto --resume ${token} --name ${name}`, expect.any(Function));
|
||||
expect(db.prepare("SELECT status, resume_token FROM sessions WHERE id = ?").get(old.id)).toEqual({ status: "exited", resume_token: token });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
expect(tmux.sendKeys).not.toHaveBeenCalled();
|
||||
await new SeatIdentityReconciler({ db, tmux, listProcesses }).reconcileAll();
|
||||
vi.mocked(tmux.sendText).mockClear(); vi.mocked(tmux.sendKeys).mockClear();
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} });
|
||||
const sent = await transport.send(name, "ordinary restored message");
|
||||
// A PID absent from the observation is uncertainty, not a positively wrong pane.
|
||||
const delivers = mode === "exact" || mode === "native" || mode === "unobserved-pane-process";
|
||||
expect(sent, JSON.stringify(sent)).toMatchObject({ ok: delivers });
|
||||
if (mode === "unobserved-pane-process") expect(sent.warning).toContain("without verified native identity");
|
||||
if (mode !== "exact") expect(sent.reason).not.toBe("tmux_unavailable");
|
||||
expect(tmux.sendText).toHaveBeenCalledTimes(delivers ? 1 : 0);
|
||||
expect(tmux.sendKeys).toHaveBeenCalledTimes(delivers ? 1 : 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// #260: the managed Claude capability query (`claude --help`) must tolerate a slow but
|
||||
// valid help under load, and still end a hung query at a bounded timeout. Drives the real
|
||||
// ClaudeManagedLaunch.prepare() and real execFile against private fake executables; no
|
||||
// provider, tmux, daemon or credentials. Minimal tables as in s03-bound-launch.test.ts.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
|
||||
const CHOICES = ' --permission-mode <mode> Permission mode to use for the session (choices: "acceptEdits", "auto", "default", "plan")';
|
||||
const WITHOUT_AUTO = ' --permission-mode <mode> Permission mode to use for the session (choices: "acceptEdits", "default", "plan")';
|
||||
const QUERY_FAILED = "Claude managed capability query failed; no fallback was selected.";
|
||||
|
||||
const open: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of open.splice(0)) db.close(); });
|
||||
|
||||
function fixture(script: string) {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "claude-help-timeout-")));
|
||||
const cwd = path.join(root, "seat"); const bin = path.join(root, "bin"); const calls = path.join(root, "calls");
|
||||
mkdirSync(cwd); mkdirSync(bin); mkdirSync(path.join(root, "home"));
|
||||
writeFileSync(path.join(bin, "claude"), `#!/bin/sh\necho call >> '${calls}'\n${script}\n`);
|
||||
chmodSync(path.join(bin, "claude"), 0o755);
|
||||
const db = new Database(":memory:"); open.push(db);
|
||||
db.exec(`CREATE TABLE nodes(id TEXT, runtime TEXT, cwd TEXT);
|
||||
CREATE TABLE bindings(id TEXT, node_id TEXT, tmux_session TEXT, tmux_pane TEXT);
|
||||
CREATE TABLE occupant_tenures(node_id TEXT, generation_uuid TEXT, generation_ordinal INTEGER);`);
|
||||
db.prepare("INSERT INTO nodes VALUES ('node','claude-code',?)").run(cwd);
|
||||
db.exec("INSERT INTO bindings VALUES ('binding','node','seat','%1'); INSERT INTO occupant_tenures VALUES ('node','generation-1',1)");
|
||||
const managed = new ClaudeManagedLaunch(db, { PATH: `${bin}:/usr/bin:/bin`, HOME: path.join(root, "home") }, {});
|
||||
const helpCalls = () => existsSync(calls) ? readFileSync(calls, "utf8").trim().split("\n").length : 0;
|
||||
return { managed, helpCalls };
|
||||
}
|
||||
|
||||
async function prepare(script: string, mode = "auto", target: { cwd?: string } = {}) {
|
||||
const f = fixture(script);
|
||||
const started = Date.now();
|
||||
try {
|
||||
await f.managed.prepare({ nodeId: "node", ...target }, mode);
|
||||
return { ok: true as const, ms: Date.now() - started, helpCalls: f.helpCalls() };
|
||||
} catch (error) {
|
||||
return { ok: false as const, ms: Date.now() - started, helpCalls: f.helpCalls(), message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
describe("ClaudeManagedLaunch capability query timeout (#260)", () => {
|
||||
it("accepts a valid help that takes longer than one second", async () => {
|
||||
const result = await prepare(`sleep 1.5; printf '%s\\n' '${CHOICES}'`);
|
||||
expect(result).toMatchObject({ ok: true, helpCalls: 1 });
|
||||
expect(result.ms).toBeGreaterThanOrEqual(1400);
|
||||
}, 15_000);
|
||||
|
||||
it("still ends a hung help at the bounded timeout with the same refusal", async () => {
|
||||
const result = await prepare("exec sleep 30");
|
||||
expect(result).toMatchObject({ ok: false, message: QUERY_FAILED, helpCalls: 1 });
|
||||
// Waited for the 5 s bound, and ended long before the fake's 30 s sleep. The ceiling is
|
||||
// deliberately loose: it proves the query was cut off, not a scheduling-time bound.
|
||||
expect(result.ms).toBeGreaterThanOrEqual(4800);
|
||||
expect(result.ms).toBeLessThan(25_000);
|
||||
}, 40_000);
|
||||
|
||||
it("refuses an immediate non-zero exit without waiting", async () => {
|
||||
const result = await prepare("exit 1");
|
||||
expect(result).toMatchObject({ ok: false, message: QUERY_FAILED });
|
||||
expect(result.ms).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("refuses an empty successful help as unavailable options", async () => {
|
||||
expect(await prepare("exit 0")).toMatchObject({ ok: false, message: "Claude permission options are unavailable; selection was not changed." });
|
||||
});
|
||||
|
||||
it("refuses a requested mode the installed help does not advertise", async () => {
|
||||
expect(await prepare(`printf '%s\\n' '${WITHOUT_AUTO}'`)).toMatchObject({
|
||||
ok: false, message: "Claude permission mode 'auto' is not supported by the installed harness." });
|
||||
});
|
||||
|
||||
it("refuses a target that disagrees with the current binding before querying help", async () => {
|
||||
const result = await prepare(`printf '%s\\n' '${CHOICES}'`, "auto", { cwd: "/not/the/bound/cwd" });
|
||||
expect(result).toMatchObject({ ok: false, helpCalls: 0,
|
||||
message: "Claude managed launch target disagrees with the current binding; no input or selection changed." });
|
||||
});
|
||||
});
|
||||
@@ -172,6 +172,48 @@ describe("ClaudeResumeAdapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
["2.1.283", "Restored conversation\n❯\n ⏵⏵ bypass permissions on (shift+tab to cycle)"],
|
||||
["zsh", "Accessing workspace:\nYes, I trust this folder"],
|
||||
])("preserves inconclusive observations without claiming resume (%s)", async (command, content) => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => command,
|
||||
capturePaneContent: async () => content,
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code: "attention_required", message: expect.stringContaining("launch retained") });
|
||||
});
|
||||
|
||||
it.each(["command", "capture"])("preserves the launch when %s observation throws", async failedRead => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => { if (failedRead === "command") throw new Error("observation unavailable"); return "2.1.283"; },
|
||||
capturePaneContent: async () => { throw new Error("observation unavailable"); },
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code: "attention_required", message: expect.stringContaining("observation unavailable") });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["No conversation found", "retry_fresh"],
|
||||
["How would you like to resume?\n❯ Resume from summary\n Resume full session as-is", "attention_required"],
|
||||
])("retains the final observation's distinction: %s", async (content, code) => {
|
||||
const capturePaneContent = vi.fn().mockResolvedValueOnce("").mockResolvedValue(content);
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => "2.1.283", capturePaneContent,
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code });
|
||||
});
|
||||
|
||||
it("retains positive returned-to-shell refusal", async () => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux({
|
||||
getPaneCommand: async () => "zsh", capturePaneContent: async () => "$ ",
|
||||
}), { pollMs: 0, maxWaitMs: 0 });
|
||||
expect(await adapter.resume("worker", "claude_id", "retained-history", "/repo"))
|
||||
.toMatchObject({ ok: false, code: "retry_fresh" });
|
||||
});
|
||||
|
||||
it("waits for Claude to become the foreground command before succeeding", async () => {
|
||||
const getPaneCommand = vi
|
||||
.fn<(_: string) => Promise<string | null>>()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, mkdirSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
|
||||
// The child keeps Node's normal unhandled-rejection behavior. Use the built daemon
|
||||
// in package tests; the native source audit may supply its own checkout explicitly.
|
||||
const [phase, home, sourceRoot] = process.argv.slice(2);
|
||||
const moduleRoot = sourceRoot ? join(sourceRoot, "packages/daemon/src") : fileURLToPath(new URL("../../dist/", import.meta.url));
|
||||
const load = (path) => import(pathToFileURL(join(moduleRoot, path + (sourceRoot ? ".ts" : ".js"))));
|
||||
const { startSocketInbound } = await load("domain/gateway/slack/socket-inbound");
|
||||
const { InboundRouter } = await load("domain/gateway/slack/inbound");
|
||||
const { SeenStore, DeadLetterStore, InboundReceiptStore } = await load("domain/gateway/slack/state-store");
|
||||
const { makeQueuePorts } = await load("domain/gateway/slack/queue-access");
|
||||
const { createDb } = await load("db/connection");
|
||||
const { migrate } = await load("db/migrate");
|
||||
const { ALL_MIGRATIONS } = await load("db/all-migrations");
|
||||
const { QueueRepository } = await load("domain/queue-repository");
|
||||
const { EventBus } = await load("domain/event-bus");
|
||||
const deadDir = join(home, "dead");
|
||||
mkdirSync(deadDir);
|
||||
const dead = new DeadLetterStore(join(deadDir, "letters.jsonl"));
|
||||
const seen = new SeenStore(join(home, "seen.jsonl"));
|
||||
const db = createDb(join(home, "state.sqlite"));
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = new QueueRepository(db, new EventBus(db));
|
||||
const router = new InboundRouter({
|
||||
queue: makeQueuePorts(repo), seen, deadLetter: dead, destination: "operator-agent@kernel",
|
||||
resolveSender: () => ({ admitted: true, source: "human-fixture@external" }),
|
||||
});
|
||||
const event = { type: "message", user: "U-FIXTURE", channel: "C-FIXTURE", text: "durable recovery", ts: "1000.1" };
|
||||
let faults = 0;
|
||||
let requests = 0;
|
||||
const sockets = new Set();
|
||||
const server = createServer((_request, response) => {
|
||||
requests++;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ ok: true, url: `ws://127.0.0.1:${server.address().port}/socket` }));
|
||||
});
|
||||
server.on("upgrade", (request, socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
const accept = createHash("sha1").update(String(request.headers["sec-websocket-key"]) + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
|
||||
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
||||
socket.once("data", () => socket.end(Buffer.from([0x88, 0])));
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const inject = () => { dead.append(event, 1); chmodSync(deadDir, 0o500); };
|
||||
if (phase === "onopen") inject();
|
||||
let handle;
|
||||
try {
|
||||
handle = startSocketInbound("synthetic-app-token", router, {
|
||||
retryIntervalMs: 30,
|
||||
receipts: new InboundReceiptStore(join(home, "receipts.jsonl")),
|
||||
fetchImpl: (_url, init) => fetch(`http://127.0.0.1:${server.address().port}`, init),
|
||||
log: (message) => {
|
||||
if (!message.includes("dead-letter retry failed")) return;
|
||||
assert.match(message, /EACCES|EPERM/);
|
||||
assert.equal(dead.readAll().length, 1, "failed rewrite must retain the owed disk entry");
|
||||
faults++;
|
||||
if (faults === 2) chmodSync(deadDir, 0o700);
|
||||
},
|
||||
});
|
||||
const deadline = Date.now() + 5000;
|
||||
while (handle.status().state !== "connected" && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(handle.status().state, "connected");
|
||||
if (phase === "periodic") inject();
|
||||
while ((faults < 2 || dead.readAll().length > 0) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(faults, 2, "both errors logged without terminating the child");
|
||||
assert.equal(dead.readAll().length, 0, "next scheduled pass recovers after the real fault clears");
|
||||
assert.equal(repo.list({ limit: 100 }).length, 1, "seen delivery is not duplicated by retry");
|
||||
assert.ok(seen.load().has(event.ts));
|
||||
assert.equal(handle.status().state, "connected");
|
||||
assert.equal(requests, 1, "recovery does not require reconnecting");
|
||||
console.log(JSON.stringify({ phase, faults, remaining: 0, rows: 1, state: "connected", requests }));
|
||||
} finally {
|
||||
chmodSync(deadDir, 0o700);
|
||||
handle?.stop();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
db.close();
|
||||
}
|
||||
@@ -111,7 +111,7 @@ import { PodBundleSourceResolver } from "../../src/domain/bundle-source-resolver
|
||||
import { NodeCmuxService } from "../../src/domain/node-cmux-service.js";
|
||||
import { AgentActivityStore } from "../../src/domain/agent-activity-store.js";
|
||||
import { SeatAttentionReconciler } from "../../src/domain/seat-attention-reconciler.js";
|
||||
import { createApp } from "../../src/server.js";
|
||||
import { createApp, createAppWithWebSocket } from "../../src/server.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
@@ -260,6 +260,10 @@ export function createTestApp(
|
||||
/** Wire the ready runtime adapters into the routes' `runtimeAdapters`, as startup does, so a
|
||||
* route launch can start harnesses. Off by default: existing tests keep no route adapters. */
|
||||
wireRuntimeAdapters?: boolean;
|
||||
/** Extra or overriding createApp deps (browser-boundary route tests inject inert spies). */
|
||||
appDeps?: Partial<import("../../src/server.js").AppDeps>;
|
||||
/** Build through createAppWithWebSocket (production upgrade path) and return injectWebSocket. */
|
||||
withWebSocket?: boolean;
|
||||
/**
|
||||
* Agent Starter v1 vertical M2 R2: optionally expose the in-test
|
||||
* StartupOrchestrator + PodRigInstantiator so callers can spy on
|
||||
@@ -398,7 +402,7 @@ export function createTestApp(
|
||||
};
|
||||
const upRouter = new UpCommandRouter({ fsOps: upRouterFs });
|
||||
|
||||
const app = createApp({
|
||||
const testAppDeps = {
|
||||
rigRepo, sessionRegistry, eventBus, nodeLauncher, startupOrchestrator, tmuxAdapter: tmux, cmuxAdapter: cmux,
|
||||
snapshotCapture, snapshotRepo, restoreOrchestrator,
|
||||
rigSpecExporter, rigSpecPreflight, rigInstantiator,
|
||||
@@ -429,9 +433,15 @@ export function createTestApp(
|
||||
// and pass it here explicitly.
|
||||
permissionDriftObserver: opts?.permissionDriftObserver ?? { diagnose: () => null },
|
||||
runtimeAdapters: opts?.wireRuntimeAdapters ? adapters : undefined,
|
||||
});
|
||||
...opts?.appDeps,
|
||||
};
|
||||
// withWebSocket: the production createAppWithWebSocket path, returning its injectWebSocket.
|
||||
const built = opts?.withWebSocket
|
||||
? createAppWithWebSocket(testAppDeps as never)
|
||||
: { app: createApp(testAppDeps as never), injectWebSocket: undefined };
|
||||
const app = built.app;
|
||||
return {
|
||||
app, rigRepo, sessionRegistry, eventBus, nodeLauncher, snapshotRepo,
|
||||
app, injectWebSocket: built.injectWebSocket, rigRepo, sessionRegistry, eventBus, nodeLauncher, snapshotRepo,
|
||||
snapshotCapture, checkpointStore, restoreOrchestrator,
|
||||
rigSpecExporter, rigSpecPreflight, rigInstantiator,
|
||||
packageRepo, installRepo, installEngine, installVerifier,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// pre-B12 implementation violated: invoking the sampler must hand control back to the event loop
|
||||
// immediately instead of blocking for the whole spawn. The old code ran ps via execSync inside the
|
||||
// async body, so the CALL ITSELF stalled for the full ps duration (measured ~100-220ms on this
|
||||
// class of box) and every HTTP request queued behind it. The discriminator is an ORDERING
|
||||
// class of box) and every HTTP request queued behind it. The discriminator is a SETTLEMENT
|
||||
// property, not a wall-clock bound — the inline note records why a bound was tried and dropped.
|
||||
// Door test: revert the async wrap locally and this fails; on the candidate it passes.
|
||||
|
||||
@@ -14,6 +14,23 @@ import { describe, it, expect } from "vitest";
|
||||
import { defaultListProcesses as refresherListProcesses } from "../src/domain/resume-metadata-refresher.js";
|
||||
import { defaultListProcesses as codexListProcesses } from "../src/adapters/codex-runtime-adapter.js";
|
||||
|
||||
// The discriminator. Drain microtasks only — no timers, no I/O. An async ps sampler cannot settle
|
||||
// here, because its child-process I/O needs an event-loop turn that no microtask provides. A sampler
|
||||
// that ran ps synchronously inside the call settles within a few ticks (the pre-B12/F1 shape needs
|
||||
// about 3). The depth is a bounded assumption, not a proof that nothing can block: a synchronous
|
||||
// spawn hidden behind more than MICROTASK_DRAIN_TICKS awaits would also read as pending. An earlier
|
||||
// version raced a 0ms timer against the result instead. Timer order can race: in a local probe, a
|
||||
// 70ms synchronous stall after spawning a single-PID ps let the result settle before a 0ms timer in
|
||||
// most trials. A CI failure of that version is consistent with this race (inferred; the CI timing
|
||||
// was not measured), so timer order is not asserted.
|
||||
const MICROTASK_DRAIN_TICKS = 20;
|
||||
async function settlesWithinMicrotasks(pending: Promise<unknown>): Promise<boolean> {
|
||||
let settled = false;
|
||||
void pending.then(() => { settled = true; }, () => { settled = true; });
|
||||
for (let i = 0; i < MICROTASK_DRAIN_TICKS; i++) await Promise.resolve();
|
||||
return settled;
|
||||
}
|
||||
|
||||
const SITES = [
|
||||
["resume-metadata-refresher", refresherListProcesses],
|
||||
["codex-runtime-adapter", codexListProcesses],
|
||||
@@ -31,19 +48,14 @@ describe.each(SITES)("B12-T real async list_processes — %s", (_site, listProce
|
||||
it("hands control back to the event loop instead of blocking for the spawn (RED on the pre-B12 sync implementation)", async () => {
|
||||
// Note on what is NOT asserted: the invocation's synchronous-return time. Measured here, even
|
||||
// the async implementation spends 60-80ms in the call under load (child-process spawn setup),
|
||||
// so a wall-clock bound is environment-hostage. The deterministic discriminator is ORDER: the
|
||||
// pre-B12 execSync implementation finished ps inside the call, so its promise settles on the
|
||||
// first microtask — ahead of any timer — and `turnedBeforeResolve` reads false there, always.
|
||||
// so a wall-clock bound is environment-hostage. The pre-B12 execSync implementation finished ps
|
||||
// inside the call, so its promise settles within the microtask drain; the async one cannot.
|
||||
const pending = listProcesses();
|
||||
|
||||
// While ps runs, the loop must turn: a 0ms timer armed AFTER the call must fire BEFORE the
|
||||
// (much slower) spawn resolves.
|
||||
let loopTurnedFirst = false;
|
||||
setTimeout(() => { loopTurnedFirst = true; }, 0);
|
||||
const { rows, turnedBeforeResolve } = await pending.then((r) => ({ rows: r, turnedBeforeResolve: loopTurnedFirst }));
|
||||
const settledEarly = await settlesWithinMicrotasks(pending);
|
||||
const rows = await pending;
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0); // the non-blocking return was not an empty-result shortcut
|
||||
expect(turnedBeforeResolve).toBe(true);
|
||||
expect(settledEarly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,13 +89,12 @@ describe("F1 real async resolve_home — codex-thread-id", () => {
|
||||
|
||||
it("hands control back to the event loop instead of blocking for the spawn (RED on the pre-F1 sync implementation)", async () => {
|
||||
const { defaultResolveHomeDirByPid } = await import("../src/domain/codex-thread-id.js");
|
||||
const { home, turnedBeforeResolve } = await withChild(async (pid) => {
|
||||
const pending = defaultResolveHomeDirByPid(pid);
|
||||
let loopTurnedFirst = false;
|
||||
setTimeout(() => { loopTurnedFirst = true; }, 0);
|
||||
return Promise.resolve(pending).then((h) => ({ home: h, turnedBeforeResolve: loopTurnedFirst }));
|
||||
const { home, settledEarly } = await withChild(async (pid) => {
|
||||
const pending = Promise.resolve(defaultResolveHomeDirByPid(pid));
|
||||
const settledEarly = await settlesWithinMicrotasks(pending);
|
||||
return { home: await pending, settledEarly };
|
||||
});
|
||||
expect(home).toBe("/tmp/f1-probe-home"); // the fast return was not an empty shortcut
|
||||
expect(turnedBeforeResolve).toBe(true);
|
||||
expect(settledEarly).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { it, expect } from "vitest";
|
||||
import { verifyClaudePaneProcess } from "../src/domain/native-process-lineage.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const token = "00000000-0000-4000-8000-000000000197";
|
||||
const startedAt = "Thu Oct 1 11:00:00 2026";
|
||||
const autoScreen = "Restored conversation\n─────────\n❯\u00a0\n─────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n";
|
||||
const modes = [
|
||||
"npm-name", "versioned", "versioned-argv-only", "versioned-comm-only", "wrong-token",
|
||||
"missing-token", "wrong-pane", "bare-shell", "unavailable", "pane-command-unavailable",
|
||||
"background", "other-semver", "unknown-both", "versioned-direct-pane", "missing-metadata",
|
||||
"ambiguous-pane", "changed-binding", "onboarding", "changed-process", "ambiguous-process",
|
||||
"changed-after-paste", "bare-shell-helper", "bare-shell-job",
|
||||
"wrong-token-post-read-error", "wrong-token-post-read-empty",
|
||||
"bare-shell-post-read-error", "bare-shell-post-read-empty",
|
||||
"bare-shell-second-process-unavailable", "bare-shell-first-process-unavailable",
|
||||
];
|
||||
|
||||
it.each(modes)("selector and ordinary transport: %s", async (mode) => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
|
||||
const rig = rigRepo.createRig("native-test"), node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code" });
|
||||
const name = "test-c@native-test", session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
if (mode !== "missing-metadata") sessionRegistry.updateResumeToken(session.id, "claude_id", token, "scrape");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
|
||||
const executable = ["versioned", "versioned-argv-only", "versioned-direct-pane"].includes(mode)
|
||||
? "/fixture/.local/share/claude/versions/2.1.285"
|
||||
: mode === "other-semver" ? "/unrelated/2.1.285" : "/opt/claude";
|
||||
const bare = mode.startsWith("bare-shell");
|
||||
const shell = mode === "bare-shell-helper" ? "zsh" : "bash";
|
||||
const rows = [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: bare ? 100 : 101,
|
||||
executableName: shell, command: `-${shell}`, startedAt },
|
||||
...(bare ? [] : [
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101,
|
||||
executableName: "sh", command: "/bin/sh /fixture/launch", startedAt },
|
||||
{ pid: 102, ppid: 101, pgid: mode === "background" ? 999 : 101, tpgid: 101,
|
||||
executableName: ["versioned", "versioned-comm-only", "other-semver", "versioned-direct-pane"].includes(mode) ? "2.1.285" : "claude",
|
||||
command: `${executable} --permission-mode auto ${mode === "missing-token" ? "" : `--session-id ${mode.startsWith("wrong-token") ? "different" : token}`} --name ${name}`, startedAt },
|
||||
]),
|
||||
];
|
||||
// The shell still owns the terminal; these children are in background groups.
|
||||
if (mode === "bare-shell-helper" || mode === "bare-shell-job") {
|
||||
rows.push({ pid: 103, ppid: 100, pgid: 103, tpgid: 100,
|
||||
executableName: mode === "bare-shell-helper" ? "gitstatusd" : "sleep",
|
||||
command: mode === "bare-shell-helper" ? "/fixture/gitstatusd" : "sleep 600", startedAt });
|
||||
}
|
||||
if (mode === "ambiguous-process") rows.push({ ...rows[2]!, pid: 103 });
|
||||
let reads = 0;
|
||||
const listProcesses = async () => {
|
||||
if (["unavailable", "unknown-both"].includes(mode)) throw new Error("fixture unavailable");
|
||||
reads++;
|
||||
if ((mode === "bare-shell-second-process-unavailable" && reads === 2)
|
||||
|| (mode === "bare-shell-first-process-unavailable" && reads === 1)) throw new Error("process sample unavailable");
|
||||
return mode === "changed-process" && reads % 2 === 0
|
||||
? rows.map(row => row.pid === 102 ? { ...row, startedAt: "replacement" } : row) : rows;
|
||||
};
|
||||
const calls: string[] = [];
|
||||
let paneReads = 0;
|
||||
const tmux = {
|
||||
hasSession: async () => true,
|
||||
probeSession: async () => ({ state: "present" }),
|
||||
getPanePid: async (target: string) => mode === "wrong-pane" && target === "%1" ? 999 : 100,
|
||||
getPaneCommand: async () => {
|
||||
if (["pane-command-unavailable", "unknown-both"].includes(mode)) throw new Error("fixture command unavailable");
|
||||
return mode === "versioned-direct-pane" ? "2.1.285" : "sh";
|
||||
},
|
||||
listPanes: async () => {
|
||||
paneReads++;
|
||||
if (paneReads > 1 && mode.endsWith("post-read-error")) throw new Error("later pane observation unavailable");
|
||||
if (paneReads > 1 && mode.endsWith("post-read-empty")) return [];
|
||||
return mode === "ambiguous-pane" ? [{ id: "%1" }, { id: "%2" }] : [{ id: "%1" }];
|
||||
},
|
||||
capturePaneContent: async () => {
|
||||
if (mode === "changed-binding") sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%2" });
|
||||
return mode === "onboarding" ? "Do you trust the files in this folder?\n❯ 1. Yes, I trust this folder\n 2. No, exit"
|
||||
: bare ? "admin@fixture ~ % " : autoScreen;
|
||||
},
|
||||
sendText: async () => {
|
||||
calls.push("text");
|
||||
if (mode === "changed-after-paste") sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%2" });
|
||||
return { ok: true };
|
||||
},
|
||||
sendKeys: async () => { calls.push("enter"); return { ok: true }; },
|
||||
} as unknown as TmuxAdapter;
|
||||
const observation = await verifyClaudePaneProcess({ target: "%1", tmux, listProcesses, expectedToken: token });
|
||||
reads = 0; // Each consumer starts its own observation sequence.
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} });
|
||||
const sent = await transport.send(name, "harmless fixture message");
|
||||
const expectedSend = [
|
||||
"npm-name", "versioned", "pane-command-unavailable", "unknown-both", "versioned-direct-pane",
|
||||
"missing-token", "unavailable", "background", "other-semver", "missing-metadata",
|
||||
"versioned-argv-only", "versioned-comm-only",
|
||||
].includes(mode);
|
||||
if (bare) expect({ ok: sent.ok, calls }).toEqual({ ok: false, calls: [] });
|
||||
expect(sent.ok).toBe(expectedSend);
|
||||
expect(calls).toEqual(expectedSend ? ["text", "enter"] : mode === "changed-after-paste" ? ["text"] : []);
|
||||
// A strict process selector proves only the supplied process/token, not the DB/pane binding.
|
||||
expect(observation !== null).toBe([
|
||||
"npm-name", "versioned", "pane-command-unavailable", "versioned-direct-pane", "missing-metadata",
|
||||
"ambiguous-pane", "changed-binding", "onboarding", "changed-after-paste",
|
||||
].includes(mode));
|
||||
if (expectedSend && ["unavailable", "unknown-both", "missing-token", "missing-metadata", "background", "other-semver", "versioned-argv-only", "versioned-comm-only"].includes(mode)) {
|
||||
expect(sent.warning).toContain("without verified native identity");
|
||||
}
|
||||
reads = 0; paneReads = 0;
|
||||
const queue = new QueueRepository(db, eventBus, { transport, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
// Exercise the actual wake consumer without an API, scheduler, or native process.
|
||||
const wake = await (queue as unknown as {
|
||||
performWakeSend(id: string, destination: string, sender: string): Promise<{ classified: string }>;
|
||||
}).performWakeSend("fixture-q197", name, "fixture-sender@native-test");
|
||||
expect(wake.classified).toBe(expectedSend ? "indeterminate" : "failed");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
@@ -7,6 +7,31 @@ import {
|
||||
} from "../src/domain/native-resume-probe.js";
|
||||
|
||||
describe("native resume probe", () => {
|
||||
describe("headerless Claude auto-mode requires managed identity proof", () => {
|
||||
const screen = "Restored conversation\n❯\u00a0\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n";
|
||||
it("keeps every screen-only caller conservative", () => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: screen }))
|
||||
.toMatchObject({ status: "inconclusive", code: "claude_auto_identity_required" });
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: screen, claudeAutoIdentityVerified: true }))
|
||||
.toMatchObject({ status: "resumed", code: "active_runtime" });
|
||||
});
|
||||
it.each([
|
||||
" ⏵⏵ auto mode on (shift+tab to cycle)",
|
||||
"❯\nThe manual says auto mode on (shift+tab to cycle)",
|
||||
"❯\n ⏵⏵ auto mode on",
|
||||
])("still needs the actual prompt/footer shape: %s", (paneContent) => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent, claudeAutoIdentityVerified: true }).status).not.toBe("resumed");
|
||||
});
|
||||
it.each([
|
||||
["Accessing workspace:\nYes, I trust this folder", "trust_gate"],
|
||||
["new MCP servers found in .mcp.json\nSelect any you wish to enable\nEnter to confirm", "mcp_gate"],
|
||||
["Not logged in · Run /login", "login_required"],
|
||||
["How would you like to resume?\n❯ Resume from summary\n Resume full session as-is", "claude_resume_selection_prompt"],
|
||||
["No conversation found", "no_conversation_found"],
|
||||
])("does not let identity proof waive prerequisites: %s", (panel, code) => {
|
||||
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: `${screen}\n${panel}`, claudeAutoIdentityVerified: true }).code).toBe(code);
|
||||
});
|
||||
});
|
||||
describe("issue116 headerless custom status lines", () => {
|
||||
const reportedFooter = " 5h 71% left · weekly 24% left · GPT-6-Astra high · Context 81% left";
|
||||
it.each(["›", "»"])("recognizes the reported footer below a %s conversation prompt", (prompt) => {
|
||||
|
||||
@@ -7,19 +7,27 @@ import type { RigSpec } from "../src/domain/types.js";
|
||||
|
||||
// -- Mock filesystem --
|
||||
|
||||
function mockFs(files: Record<string, string>): PodAssemblerFsOps {
|
||||
const written: Record<string, string> = {};
|
||||
function mockFs(files: Record<string, string | Uint8Array>): PodAssemblerFsOps {
|
||||
const written: Record<string, string | Uint8Array> = {};
|
||||
const dirs = new Set<string>();
|
||||
const read = (p: string): string | Uint8Array => {
|
||||
if (p in files) return files[p]!;
|
||||
if (p in written) return written[p]!;
|
||||
throw new Error(`File not found: ${p}`);
|
||||
};
|
||||
|
||||
return {
|
||||
readFile: (p: string) => {
|
||||
if (p in files) return files[p]!;
|
||||
if (p in written) return written[p]!;
|
||||
throw new Error(`File not found: ${p}`);
|
||||
const v = read(p);
|
||||
return typeof v === "string" ? v : Buffer.from(v).toString("utf8");
|
||||
},
|
||||
readFileBuffer: (p: string) => {
|
||||
const v = read(p);
|
||||
return typeof v === "string" ? Buffer.from(v, "utf8") : v;
|
||||
},
|
||||
exists: (p: string) => p in files || p in written,
|
||||
mkdirp: (p: string) => { dirs.add(p); },
|
||||
writeFile: (p: string, content: string) => { written[p] = content; },
|
||||
writeFile: (p: string, content: string | Uint8Array) => { written[p] = content; },
|
||||
copyDir: () => {},
|
||||
listFiles: (dirPath: string) => {
|
||||
const result: string[] = [];
|
||||
@@ -31,7 +39,7 @@ function mockFs(files: Record<string, string>): PodAssemblerFsOps {
|
||||
return result;
|
||||
},
|
||||
_written: written, // for test inspection
|
||||
} as PodAssemblerFsOps & { _written: Record<string, string> };
|
||||
} as PodAssemblerFsOps & { _written: Record<string, string | Uint8Array> };
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
@@ -463,6 +471,33 @@ describe("PodBundleAssembler", () => {
|
||||
});
|
||||
|
||||
// T11: integration: assemble -> verify manifest + file contents
|
||||
it("copies agent-package and rig files as bytes, never decoding them", () => {
|
||||
const spec = makeRigSpec({
|
||||
cultureFile: "culture.md",
|
||||
startup: { files: [{ path: "startup/blob.bin", deliveryHint: "auto", required: true, appliesOn: ["fresh_start", "restore"] }], actions: [] },
|
||||
});
|
||||
const binary = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x80, 0x00, 0xc3, 0x28, 0xff]);
|
||||
const culture = Uint8Array.from([0x23, 0x20, 0xa0, 0xa1, 0x0a]);
|
||||
const startup = Uint8Array.from([0xfe, 0xed, 0x00, 0xc0]);
|
||||
const files: Record<string, string | Uint8Array> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/culture.md`]: culture,
|
||||
[`${RIG_ROOT}/startup/blob.bin`]: startup,
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
[`${RIG_ROOT}/agents/impl/assets/logo.png`]: binary,
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
new PodBundleAssembler({ fsOps: fs }).assemble({
|
||||
rigRoot: RIG_ROOT, rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/staging", bundleName: "bytes", bundleVersion: "1.0.0",
|
||||
});
|
||||
|
||||
const written = (fs as unknown as { _written: Record<string, string | Uint8Array> })._written;
|
||||
expect(Buffer.from(written["/tmp/staging/agents/impl/assets/logo.png"]!).equals(Buffer.from(binary))).toBe(true);
|
||||
expect(Buffer.from(written["/tmp/staging/culture.md"]!).equals(Buffer.from(culture))).toBe(true);
|
||||
expect(Buffer.from(written["/tmp/staging/startup/blob.bin"]!).equals(Buffer.from(startup))).toBe(true);
|
||||
});
|
||||
|
||||
it("integration: assembled bundle has correct manifest and files", () => {
|
||||
const spec = makeRigSpec({
|
||||
cultureFile: "culture.md",
|
||||
@@ -492,7 +527,7 @@ describe("PodBundleAssembler", () => {
|
||||
// Verify written files exist
|
||||
const written = (fs as unknown as { _written: Record<string, string> })._written;
|
||||
expect(written["/tmp/staging/rig.yaml"]).toBeDefined();
|
||||
expect(written["/tmp/staging/culture.md"]).toBe("# Culture doc");
|
||||
expect(Buffer.from(written["/tmp/staging/culture.md"]!).toString("utf8")).toBe("# Culture doc");
|
||||
expect(written["/tmp/staging/bundle.yaml"]).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ function claudeNode(overrides?: Partial<NodeInventoryEntry> & { cwd?: string | n
|
||||
function startupContextProbe(options?: {
|
||||
status?: "ok" | "missing" | "malformed" | "probe_error";
|
||||
evidence?: string;
|
||||
resolvedStartupFiles?: Array<{ absolutePath: string; required: boolean; path?: string; deliveryHint?: string }>;
|
||||
projectionEntries?: Array<{ absolutePath: string; effectiveId?: string; category?: string }>;
|
||||
resolvedStartupFiles?: Array<{ absolutePath: string; required: boolean; path?: string; deliveryHint?: string; ownerRoot?: string }>;
|
||||
projectionEntries?: Array<{ absolutePath: string; effectiveId?: string; category?: string; sourcePath?: string }>;
|
||||
runtime?: string;
|
||||
}) {
|
||||
if (options?.status && options.status !== "ok") {
|
||||
@@ -1478,6 +1478,87 @@ describe("RestoreCheckService", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
// #261: restore-check inspects and REPORTS the paths replay will use. A stale old-install built-in or
|
||||
// shipped-spec resource is judged at the running install; red/yellow semantics are unchanged.
|
||||
describe("#261 stale old-install paths are judged at the running install", () => {
|
||||
const OLD = "/old-openrig/lib/node_modules/@openrig/cli/daemon";
|
||||
const RUN_ASSETS = path.resolve(import.meta.dirname, "../assets");
|
||||
const RUN_SPECS = path.resolve(import.meta.dirname, "../specs");
|
||||
const files = [{ path: "CULTURE-default.md", absolutePath: `${OLD}/assets/guidance/CULTURE-default.md`, ownerRoot: `${OLD}/assets`, required: true }];
|
||||
const entries = [{ absolutePath: `${OLD}/specs/agents/shared/runtime/claude-settings.fragment.json`, sourcePath: `${OLD}/specs/agents/shared`, effectiveId: "shared:claude-default-settings", category: "runtime_resource" }];
|
||||
const stoppedNode = () => claudeNode({ sessionStatus: "stopped", startupStatus: "failed", latestError: "seat crashed" });
|
||||
|
||||
it("old install removed, current present: green, and evidence names only the running paths", () => {
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: files, projectionEntries: entries }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists: (p) => !p.startsWith("/old-openrig"),
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("green");
|
||||
expect(startup.evidence).toContain(`${RUN_ASSETS}/guidance/CULTURE-default.md`);
|
||||
expect(startup.evidence).toContain(`${RUN_SPECS}/agents/shared/runtime/claude-settings.fragment.json`);
|
||||
expect(startup.evidence).not.toContain("/old-openrig");
|
||||
});
|
||||
|
||||
it("current built-in genuinely missing on a non-ready seat: still red, reporting the running path", () => {
|
||||
const runningCulture = `${RUN_ASSETS}/guidance/CULTURE-default.md`;
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: files }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists: (p) => !p.startsWith("/old-openrig") && p !== runningCulture,
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("red");
|
||||
expect(startup.evidence).toContain(runningCulture);
|
||||
expect(startup.remediation).toContain(runningCulture);
|
||||
expect(startup.evidence).not.toContain("/old-openrig");
|
||||
});
|
||||
|
||||
it("shipped-spec rig culture: stale old path is judged at the running specs (green when present, red naming it when missing)", () => {
|
||||
const culture = [{ path: "culture/CULTURE.md", absolutePath: `${OLD}/specs/rigs/launch/kernel/culture/CULTURE.md`, ownerRoot: `${OLD}/specs/rigs/launch/kernel`, required: true }];
|
||||
const runningCulture = `${RUN_SPECS}/rigs/launch/kernel/culture/CULTURE.md`;
|
||||
const check = (exists: (p: string) => boolean) => (new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: culture }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists,
|
||||
})).check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
const present = check((p) => !p.startsWith("/old-openrig"));
|
||||
expect(present.status).toBe("green");
|
||||
expect(present.evidence).toContain(runningCulture);
|
||||
const missing = check((p) => !p.startsWith("/old-openrig") && p !== runningCulture);
|
||||
expect(missing.status).toBe("red");
|
||||
expect(missing.evidence).toContain(runningCulture);
|
||||
expect(missing.evidence).not.toContain("/old-openrig");
|
||||
});
|
||||
|
||||
it("current built-in missing on a running/ready seat stays yellow (red/yellow rule unchanged)", () => {
|
||||
const runningCulture = `${RUN_ASSETS}/guidance/CULTURE-default.md`;
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: files }) as never,
|
||||
exists: (p) => !p.startsWith("/old-openrig") && p !== runningCulture,
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("yellow");
|
||||
});
|
||||
|
||||
it("a missing custom file with a built-in basename is reported as stored, still red on a non-ready seat", () => {
|
||||
const custom = [{ path: "CULTURE-default.md", absolutePath: "/user-rig/CULTURE-default.md", ownerRoot: "/user-rig", required: true }];
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
getNodeInventory: () => [stoppedNode()],
|
||||
getStartupContext: () => startupContextProbe({ resolvedStartupFiles: custom }) as never,
|
||||
getLatestSnapshot: () => ({ id: "snap-261", kind: "auto-pre-down" }),
|
||||
exists: (p) => p !== "/user-rig/CULTURE-default.md",
|
||||
}));
|
||||
const startup = (service.check({ noQueue: true, noHooks: true }) as any).checks.find((c: { check: string }) => c.check === "[email protected]");
|
||||
expect(startup.status).toBe("red");
|
||||
expect(startup.evidence).toContain("/user-rig/CULTURE-default.md");
|
||||
});
|
||||
});
|
||||
|
||||
it("running/ready node with missing required startup file is a yellow caveat", () => {
|
||||
const requiredPath = path.join(os.tmpdir(), "restore-check-startup-required-ready-missing.md");
|
||||
const service = new RestoreCheckService(mockDeps({
|
||||
|
||||
@@ -1122,6 +1122,45 @@ describe("RestoreOrchestrator", () => {
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it.each(["opaque", "command-error", "capture-error", "null"])("legacy %s observation preserves current binding and retained history", async mode => {
|
||||
const snap = seedRigAndSnapshot({
|
||||
nodes: [{ logicalId: "worker", role: "worker", runtime: "claude-code" }],
|
||||
edges: [], resumeType: "claude_id", resumeToken: "retained-history", withBinding: "worker",
|
||||
});
|
||||
const nodeId = snap.data.nodes[0]!.id;
|
||||
const readState = () => ({
|
||||
binding: sessionRegistry.getBindingForNode(nodeId),
|
||||
sessions: db.prepare("SELECT id, status, resume_type, resume_token FROM sessions WHERE node_id=? ORDER BY id").all(nodeId),
|
||||
});
|
||||
const tmux = mockTmux();
|
||||
vi.mocked(tmux.getPaneCommand).mockImplementation(async () => {
|
||||
if (mode === "command-error") throw new Error("command observation unavailable");
|
||||
return mode === "null" ? null : "2.1.283";
|
||||
});
|
||||
vi.mocked(tmux.capturePaneContent).mockImplementation(async () => {
|
||||
if (mode === "capture-error") throw new Error("capture observation unavailable");
|
||||
return mode === "null" ? null : "Restored conversation\n❯\n ⏵⏵ bypass permissions on (shift+tab to cycle)";
|
||||
});
|
||||
const claude = new ClaudeResumeAdapter(tmux, { pollMs: 0, maxWaitMs: 0 });
|
||||
const resume = claude.resume.bind(claude);
|
||||
let afterLaunch: ReturnType<typeof readState> | undefined;
|
||||
vi.spyOn(claude, "resume").mockImplementation(async (...args) => {
|
||||
afterLaunch = readState();
|
||||
return resume(...args);
|
||||
});
|
||||
const result = await createOrchestrator({ tmux, claude }).restore(snap.id);
|
||||
expect(result).toMatchObject({ ok: true, result: { rigResult: "partially_restored", nodes: [{ status: "attention_required" }] } });
|
||||
expect(afterLaunch?.binding).not.toBeNull();
|
||||
expect(afterLaunch).toBeDefined();
|
||||
expect(readState()).toEqual(afterLaunch);
|
||||
expect(readState().sessions).toContainEqual(expect.objectContaining({ resume_type: "claude_id", resume_token: "retained-history" }));
|
||||
expect(readState().sessions).toContainEqual(expect.objectContaining({ status: "running", resume_token: null }));
|
||||
expect(tmux.killSession).not.toHaveBeenCalled();
|
||||
// Only the resume command and its submit; never orientation/checkpoint input.
|
||||
expect(tmux.sendText).toHaveBeenCalledTimes(1);
|
||||
expect(tmux.sendKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("legacy Claude resume verification failure -> status 'failed'", async () => {
|
||||
const snap = seedRigAndSnapshot({
|
||||
nodes: [{ logicalId: "worker", role: "worker", runtime: "claude-code" }],
|
||||
@@ -3645,4 +3684,135 @@ describe("RestoreOrchestrator", () => {
|
||||
expect(subsetEvents[0]?.result?.warnings?.some((w) => w.includes("FR-5"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// #261: stored built-in startup files follow the running install on restore (pre-validation + replay);
|
||||
// custom files are delivered exactly as stored; a same-native resume still replays nothing.
|
||||
describe("#261 built-in startup files follow the running install", () => {
|
||||
const OLD_ASSETS = "/old-openrig/lib/node_modules/@openrig/cli/daemon/assets";
|
||||
const RUNNING_ASSETS = path.resolve(import.meta.dirname, "../assets");
|
||||
const builtin = (name: string, rel: string) => ({
|
||||
path: name, absolutePath: `${OLD_ASSETS}/${rel}`, ownerRoot: OLD_ASSETS,
|
||||
deliveryHint: "guidance_merge" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const],
|
||||
});
|
||||
const customSameBasename = {
|
||||
path: "CULTURE-default.md", absolutePath: "/user-rig/CULTURE-default.md", ownerRoot: "/user-rig",
|
||||
deliveryHint: "guidance_merge" as const, required: true, appliesOn: ["fresh_start" as const, "restore" as const],
|
||||
};
|
||||
const storedFiles = [
|
||||
builtin("CULTURE-default.md", "guidance/CULTURE-default.md"),
|
||||
builtin("openrig-start.md", "guidance/openrig-start.md"),
|
||||
customSameBasename,
|
||||
];
|
||||
const notOld = (p: string) => !p.startsWith("/old-openrig");
|
||||
|
||||
function seedPodAware(resume: boolean) {
|
||||
const rig = rigRepo.createRig("test-rig");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-261", rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", podId: "pod-261" });
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-impl@test-rig");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
if (resume) sessionRegistry.updateResumeToken(session.id, "claude_id", "resume-token-261");
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(node.id, "[]", JSON.stringify(storedFiles), "[]", "claude-code");
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "test");
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
|
||||
const deliverStartup = vi.fn(async () => ({ delivered: 0, failed: [] }));
|
||||
const adapter = {
|
||||
runtime: "claude-code",
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup,
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async () => ({ ok: true as const, resumeToken: "t", resumeType: "claude_id" })),
|
||||
};
|
||||
return { snap, deliverStartup, adapter };
|
||||
}
|
||||
|
||||
it("fresh replay: old install removed -> no blocker, built-ins delivered from the running install, custom file unchanged", async () => {
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(false);
|
||||
const result = await createOrchestrator().restore(snap.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: notOld }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => c[0] as Array<{ path: string; absolutePath: string; ownerRoot: string; required: boolean; appliesOn: string[]; deliveryHint: string }>);
|
||||
const culture = delivered.filter((f) => f.path === "CULTURE-default.md");
|
||||
expect(culture.map((f) => f.absolutePath).sort()).toEqual([`${RUNNING_ASSETS}/guidance/CULTURE-default.md`, "/user-rig/CULTURE-default.md"].sort());
|
||||
expect(delivered.find((f) => f.path === "openrig-start.md")).toMatchObject({
|
||||
absolutePath: `${RUNNING_ASSETS}/guidance/openrig-start.md`, ownerRoot: RUNNING_ASSETS,
|
||||
required: true, appliesOn: ["fresh_start", "restore"], deliveryHint: "guidance_merge",
|
||||
});
|
||||
expect(delivered.some((f) => f.absolutePath.startsWith("/old-openrig"))).toBe(false);
|
||||
});
|
||||
|
||||
it("re-anchors even while the old install still exists (current shipped guidance is selected)", async () => {
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(false);
|
||||
const result = await createOrchestrator().restore(snap.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: () => true }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => c[0] as Array<{ absolutePath: string }>);
|
||||
expect(delivered.some((f) => f.absolutePath.startsWith("/old-openrig"))).toBe(false);
|
||||
expect(delivered.some((f) => f.absolutePath === `${RUNNING_ASSETS}/guidance/CULTURE-default.md`)).toBe(true);
|
||||
});
|
||||
|
||||
it("a genuinely missing CURRENT built-in still blocks restore honestly, naming the running path", async () => {
|
||||
const { snap, adapter } = seedPodAware(false);
|
||||
const runningCulture = `${RUNNING_ASSETS}/guidance/CULTURE-default.md`;
|
||||
const result = await createOrchestrator().restore(snap.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: (p) => notOld(p) && p !== runningCulture }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.result.blockers?.[0]).toMatchObject({ code: "required_startup_file_missing", path: runningCulture });
|
||||
});
|
||||
|
||||
it("extension: replay projects shipped-spec resources from the running install; plugin entry untouched; no drift warning for the old path", async () => {
|
||||
const OLD_SPECS = "/old-openrig/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const RUNNING_SPECS = path.resolve(import.meta.dirname, "../specs");
|
||||
const { snap, adapter } = seedPodAware(false);
|
||||
const entries = [
|
||||
{ category: "runtime_resource", effectiveId: "shared:claude-default-mcp", sourceSpec: "shared", sourcePath: `${OLD_SPECS}/agents/shared`, resourcePath: "r", absolutePath: `${OLD_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`, resourceType: "claude_mcp_fragment" },
|
||||
{ category: "plugin", effectiveId: "shared:openrig-core", sourceSpec: "shared", sourcePath: `${OLD_SPECS}/agents/shared`, resourcePath: "p", absolutePath: "/home/u/.openrig/plugins/openrig-core" },
|
||||
];
|
||||
const fixed = updateSnapshotData(snap, (data) => { for (const k of Object.keys(data.nodeStartupContext)) data.nodeStartupContext[k].projectionEntries = entries; });
|
||||
const result = await createOrchestrator().restore(fixed.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: notOld }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const projected = adapter.project.mock.calls.flatMap((c) => ((c[0] as { entries: Array<{ effectiveId: string; absolutePath: string }> }).entries));
|
||||
expect(projected.find((e) => e.effectiveId === "shared:claude-default-mcp")?.absolutePath).toBe(`${RUNNING_SPECS}/agents/shared/runtime/claude-mcp.fragment.json`);
|
||||
expect(projected.find((e) => e.effectiveId === "shared:openrig-core")?.absolutePath).toBe("/home/u/.openrig/plugins/openrig-core");
|
||||
if (result.ok) {
|
||||
const drift = (result.result.warnings ?? []).filter((w) => w.includes("projection_drift"));
|
||||
// The re-anchored resource raises no drift; the deliberately untouched plugin entry keeps its honest
|
||||
// "source root missing" warning for the old shipped sourcePath while its own file still projects.
|
||||
expect(drift.some((w) => w.includes("claude-mcp.fragment.json"))).toBe(false);
|
||||
expect(drift.every((w) => w.includes("source root missing") && w.includes("/old-openrig/lib/node_modules/@openrig/cli/daemon/specs/agents/shared"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("startup extension: replay delivers a shipped-spec rig culture from the running install; custom unchanged", async () => {
|
||||
const OLD_SPECS = "/old-openrig/lib/node_modules/@openrig/cli/daemon/specs";
|
||||
const RUNNING_SPECS = path.resolve(import.meta.dirname, "../specs");
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(false);
|
||||
const shippedCulture = { path: "culture/CULTURE.md", absolutePath: `${OLD_SPECS}/rigs/launch/kernel/culture/CULTURE.md`, ownerRoot: `${OLD_SPECS}/rigs/launch/kernel`, deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] };
|
||||
const fixed = updateSnapshotData(snap, (data) => { for (const k of Object.keys(data.nodeStartupContext)) data.nodeStartupContext[k].resolvedStartupFiles = [shippedCulture, customSameBasename]; });
|
||||
const result = await createOrchestrator().restore(fixed.id, {
|
||||
adapters: { "claude-code": adapter }, fsOps: { exists: notOld }, freshLogicalIds: ["dev.impl"],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => (c[0] as Array<{ absolutePath: string }>).map((f) => f.absolutePath));
|
||||
expect(delivered.sort()).toEqual([`${RUNNING_SPECS}/rigs/launch/kernel/culture/CULTURE.md`, "/user-rig/CULTURE-default.md"].sort());
|
||||
});
|
||||
|
||||
it("same-native resume: replay stays contained (no startup files delivered), stored built-ins notwithstanding", async () => {
|
||||
const { snap, deliverStartup, adapter } = seedPodAware(true);
|
||||
const orch = createOrchestrator({ listProcesses: nativeLineage("claude-code", "resume-token-261") });
|
||||
const result = await orch.restore(snap.id, { adapters: { "claude-code": adapter }, fsOps: { exists: notOld } });
|
||||
expect(result.ok).toBe(true);
|
||||
const delivered = deliverStartup.mock.calls.flatMap((c) => c[0] as unknown[]);
|
||||
expect(delivered).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
@@ -75,6 +76,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
}),
|
||||
probeSession: vi.fn(async (name: string) => alive.has(name) ? { state: "present" as const } : { state: "absent" as const }),
|
||||
hasSession: vi.fn(async (name: string) => alive.has(name)),
|
||||
startServer: vi.fn(async (): Promise<TmuxResult> => ({ ok: true })),
|
||||
listSessions: vi.fn(async () => [...alive].map((name) => ({ name, windows: 1, created: "", attached: false }))),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async (name: string) => {
|
||||
@@ -319,6 +321,144 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
|
||||
expect(alive.has(seat.sessionName)).toBe(false);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Real tmux ends its server when the last session goes (exit-empty), and a
|
||||
// probe then fails "no server running": transport_unavailable, never absence.
|
||||
// new-session and startServer() each bring a server back.
|
||||
function modelServerLifetime() {
|
||||
const server = { up: true, starts: 0 };
|
||||
const createSession = vi.mocked(tmux.createSession).getMockImplementation()!;
|
||||
vi.mocked(tmux.createSession).mockImplementation(async (...args) => {
|
||||
server.up = true;
|
||||
return createSession(...args);
|
||||
});
|
||||
vi.mocked(tmux.killSession).mockImplementation(async (name: string) => {
|
||||
killed.push(name);
|
||||
alive.delete(name);
|
||||
livePanes.delete(name);
|
||||
if (alive.size === 0) server.up = false;
|
||||
return { ok: true };
|
||||
});
|
||||
vi.mocked(tmux.probeSession).mockImplementation(async (name: string) => {
|
||||
if (!server.up) return { state: "transport_unavailable", cause: "no server running on /tmp/tmux-1000/default" };
|
||||
return alive.has(name) ? { state: "present" } : { state: "absent" };
|
||||
});
|
||||
vi.mocked(tmux.hasSession).mockImplementation(async (name: string) => server.up && alive.has(name));
|
||||
vi.mocked(tmux.startServer).mockImplementation(async () => {
|
||||
if (!server.up) {
|
||||
server.up = true;
|
||||
server.starts += 1;
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("when stopping the managed occupant ends the tmux server", () => {
|
||||
it.each(["sole", "pair"] as const)("continues the requested fresh launch for a %s seat", async (shape) => {
|
||||
const seat = seedSeat();
|
||||
const retiringGeneration = sessionRegistry.currentOccupantTenure(seat.node.id)!.generationUuid;
|
||||
if (shape === "pair") alive.add("dev-qa@fresh-rig");
|
||||
const server = modelServerLifetime();
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "sole-seat fresh" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
if (!result.ok) return;
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
expect(server.starts).toBe(shape === "sole" ? 1 : 0);
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
expect(result.generation).not.toBe(retiringGeneration);
|
||||
expect(result.supersededSessionIds).toContain(seat.session!.id);
|
||||
expect(sessionRegistry.getBindingForNode(seat.node.id)?.tmuxPane).toBe("%fresh");
|
||||
});
|
||||
|
||||
it("proves older non-terminal rows absent on the restored server before superseding them", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const older = sessionRegistry.registerSession(seat.node.id, "r00-dev-impl@fresh-rig");
|
||||
sessionRegistry.updateStatus(older.id, "running");
|
||||
const current = sessionRegistry.registerSession(seat.node.id, seat.sessionName);
|
||||
sessionRegistry.updateStatus(current.id, "running");
|
||||
sessionRegistry.updateBinding(seat.node.id, { tmuxSession: seat.sessionName, tmuxPane: "%old" });
|
||||
alive.add(seat.sessionName);
|
||||
livePanes.set(seat.sessionName, "%old");
|
||||
const server = modelServerLifetime();
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "old-row history" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready" });
|
||||
if (!result.ok) return;
|
||||
expect(server.starts).toBe(1);
|
||||
expect(vi.mocked(tmux.probeSession).mock.calls.map(([name]) => name)).toContain("r00-dev-impl@fresh-rig");
|
||||
expect(result.supersededSessionIds).toEqual(expect.arrayContaining([older.id, current.id]));
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
});
|
||||
|
||||
it("still refuses a same-name session that appears on the restored server", async () => {
|
||||
const seat = seedSeat();
|
||||
const server = modelServerLifetime();
|
||||
vi.mocked(tmux.startServer).mockImplementation(async () => {
|
||||
server.up = true;
|
||||
server.starts += 1;
|
||||
alive.add(seat.sessionName);
|
||||
livePanes.set(seat.sessionName, "%recreated");
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "recreated occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(livePanes.get(seat.sessionName)).toBe("%recreated");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the server cannot be restored", () => undefined],
|
||||
["the probe fails with a permission error", () => { throw new Error("permission denied"); }],
|
||||
])("keeps the transport refusal when %s", async (_label, afterStop) => {
|
||||
const seat = seedSeat();
|
||||
const server = modelServerLifetime();
|
||||
const probe = vi.mocked(tmux.probeSession).getMockImplementation()!;
|
||||
vi.mocked(tmux.probeSession).mockImplementation(async (name: string) => {
|
||||
if (!server.up) afterStop();
|
||||
return probe(name);
|
||||
});
|
||||
vi.mocked(tmux.startServer).mockResolvedValue({ ok: false, code: "tmux_unavailable", message: "The terminal server did not become available." });
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, stop: true, reason: "transport stays down" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
|
||||
expect(killed).toEqual([seat.sessionName]);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("neither stops nor starts a server when stop was not requested", async () => {
|
||||
const seat = seedSeat();
|
||||
const server = modelServerLifetime();
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "no stop" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "session_live" });
|
||||
expect(killed).toEqual([]);
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
expect(server).toEqual({ up: true, starts: 0 });
|
||||
});
|
||||
|
||||
it("does not start a server it did not stop: a seat with no server stays a transport refusal", async () => {
|
||||
seedSeat({ clean: true });
|
||||
const server = modelServerLifetime();
|
||||
server.up = false;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, reason: "server already down" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed" });
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("stops exactly the managed pod-aware occupant, launches fresh, and preserves sibling/work state", async () => {
|
||||
@@ -432,4 +572,83 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(sessionRegistry.currentOccupantTenure(seat.node.id)?.kind).toBe("fresh");
|
||||
expect(db.prepare("SELECT COUNT(*) AS c FROM events WHERE type = 'seat.fresh_launch_failed'").get()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
// #261: a fresh launch delivers stored built-in startup files from the RUNNING install;
|
||||
// a custom rig file with the same basename is delivered exactly as stored.
|
||||
it("#261 delivers stored built-ins from the running install and leaves custom files untouched", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const oldAssets = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/assets";
|
||||
const running = path.resolve(import.meta.dirname, "../assets");
|
||||
const meta = { deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] };
|
||||
const stored = [
|
||||
{ path: "CULTURE-default.md", absolutePath: `${oldAssets}/guidance/CULTURE-default.md`, ownerRoot: oldAssets, ...meta },
|
||||
{ path: "openrig-onboarding-01.md", absolutePath: `${oldAssets}/onboarding/01-world-and-purpose.md`, ownerRoot: oldAssets, ...meta },
|
||||
{ path: "CULTURE-default.md", absolutePath: "/project/CULTURE-default.md", ownerRoot: "/project", ...meta },
|
||||
];
|
||||
db.prepare("UPDATE node_startup_context SET resolved_files_json=? WHERE node_id=?").run(JSON.stringify(stored), seat.node.id);
|
||||
const delivered: Array<{ path: string; absolutePath: string; ownerRoot: string; required: boolean }> = [];
|
||||
adapter.deliverStartup = async (files) => { delivered.push(...(files as typeof delivered)); return { delivered: files.length, failed: [] }; };
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "issue 261" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(delivered.map((f) => f.absolutePath).sort()).toEqual([
|
||||
"/project/CULTURE-default.md",
|
||||
`${running}/guidance/CULTURE-default.md`,
|
||||
`${running}/onboarding/01-world-and-purpose.md`,
|
||||
].sort());
|
||||
expect(delivered.every((f) => f.required)).toBe(true);
|
||||
const persisted = JSON.parse((db.prepare("SELECT resolved_files_json AS j FROM node_startup_context WHERE node_id=?").get(seat.node.id) as { j: string }).j) as Array<{ absolutePath: string }>;
|
||||
expect(persisted.some((f) => f.absolutePath.startsWith(oldAssets))).toBe(false);
|
||||
});
|
||||
|
||||
// #261 extension: shipped-spec projection resources follow the running install; a plugin stored
|
||||
// outside daemon/specs and user resources are projected exactly as stored.
|
||||
it("#261 projects shipped-spec resources from the running install and leaves plugin/user entries untouched", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const oldSpecs = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const runningSpecs = path.resolve(import.meta.dirname, "../specs");
|
||||
const entry = (over: Record<string, string>) => ({ category: "runtime_resource", effectiveId: "x", sourceSpec: "shared", resourcePath: "r", ...over });
|
||||
const stored = [
|
||||
entry({ effectiveId: "shared:claude-default-settings", sourcePath: `${oldSpecs}/agents/shared`, absolutePath: `${oldSpecs}/agents/shared/runtime/claude-settings.fragment.json`, resourceType: "claude_settings_fragment" }),
|
||||
entry({ category: "plugin", effectiveId: "shared:openrig-core", sourcePath: `${oldSpecs}/agents/shared`, absolutePath: "/home/u/.openrig/plugins/openrig-core" }),
|
||||
entry({ category: "guidance", effectiveId: "role", sourceSpec: "dev.impl", sourcePath: "/project/agents/impl", absolutePath: "/project/agents/impl/guidance/role.md" }),
|
||||
];
|
||||
db.prepare("UPDATE node_startup_context SET projection_entries_json=? WHERE node_id=?").run(JSON.stringify(stored), seat.node.id);
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "issue 261 projection" });
|
||||
expect(result.ok).toBe(true);
|
||||
const byId = Object.fromEntries((projectedPlan?.entries ?? []).map((e) => [e.effectiveId, e]));
|
||||
expect(byId["shared:claude-default-settings"]).toMatchObject({
|
||||
sourcePath: `${runningSpecs}/agents/shared`, absolutePath: `${runningSpecs}/agents/shared/runtime/claude-settings.fragment.json`,
|
||||
resourceType: "claude_settings_fragment", category: "runtime_resource",
|
||||
});
|
||||
expect(byId["shared:openrig-core"]).toMatchObject({ sourcePath: `${oldSpecs}/agents/shared`, absolutePath: "/home/u/.openrig/plugins/openrig-core" });
|
||||
expect(byId["role"]).toMatchObject({ absolutePath: "/project/agents/impl/guidance/role.md" });
|
||||
});
|
||||
|
||||
// #261 startup extension: shipped-spec startup files (kernel culture, agent role/startup context) follow the running install.
|
||||
it("#261 delivers shipped-spec startup files (pre- and post-launch) from the running install", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const oldSpecs = "/mise/installs/npm-openrig-cli/0.6.2/node_modules/@openrig/cli/daemon/specs";
|
||||
const runningSpecs = path.resolve(import.meta.dirname, "../specs");
|
||||
const kernel = "rigs/launch/kernel";
|
||||
const agent = `${kernel}/agents/advisor/lead`;
|
||||
const stored = [
|
||||
{ path: "culture/CULTURE.md", absolutePath: `${oldSpecs}/${kernel}/culture/CULTURE.md`, ownerRoot: `${oldSpecs}/${kernel}`, deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] },
|
||||
{ path: "guidance/role.md", absolutePath: `${oldSpecs}/${agent}/guidance/role.md`, ownerRoot: `${oldSpecs}/${agent}`, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start", "restore"] },
|
||||
{ path: "culture/CULTURE.md", absolutePath: "/project/culture/CULTURE.md", ownerRoot: "/project", deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"] },
|
||||
];
|
||||
db.prepare("UPDATE node_startup_context SET resolved_files_json=? WHERE node_id=?").run(JSON.stringify(stored), seat.node.id);
|
||||
const delivered: string[] = [];
|
||||
adapter.deliverStartup = async (files) => { delivered.push(...files.map((f) => f.absolutePath)); return { delivered: files.length, failed: [] }; };
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "issue 261 shipped specs" });
|
||||
expect(result.ok).toBe(true);
|
||||
// Pre-launch culture and the post-launch send_text role both arrive at the running install; custom is as stored.
|
||||
expect(delivered.sort()).toEqual([
|
||||
`${runningSpecs}/${agent}/guidance/role.md`,
|
||||
`${runningSpecs}/${kernel}/culture/CULTURE.md`,
|
||||
"/project/culture/CULTURE.md",
|
||||
].sort());
|
||||
const persisted = JSON.parse((db.prepare("SELECT resolved_files_json AS j FROM node_startup_context WHERE node_id=?").get(seat.node.id) as { j: string }).j) as Array<{ path: string; absolutePath: string; deliveryHint: string }>;
|
||||
expect(persisted.find((f) => f.path === "guidance/role.md")).toMatchObject({ absolutePath: `${runningSpecs}/${agent}/guidance/role.md`, deliveryHint: "send_text" });
|
||||
expect(persisted.some((f) => f.absolutePath.startsWith(oldSpecs))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -569,26 +569,33 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// No-regression: idle default still delivers; running + force still delivers.
|
||||
it("idle target sends by default; running target now DELIVERS WITH ADVISORY (OPR.0.4.3.28 fast-follow — mid_work downgraded, busy is not a block)", async () => {
|
||||
// Idle/busy activity does not establish native identity: this fixture has no bound pane or PID.
|
||||
it("idle activity sends with a runtime advisory; busy activity adds its advisory with or without force", async () => {
|
||||
const runtimeAdvisory = "runtime: Claude runtime observation or older launch binding is unavailable; delivery proceeds without verified native identity.";
|
||||
const idleSpy = vi.fn(async () => ({ ok: true as const }));
|
||||
const idle = makeTransport(mockTmux({ capturePaneContent: async () => "Done.\n❯ \n ⏵⏵ accept edits on (shift+tab to cycle)", sendText: idleSpy }));
|
||||
const idleRes = await idle.send("dev-impl@my-rig", "hi");
|
||||
expect(idleRes.ok).toBe(true);
|
||||
expect(idleRes.warning).toBeUndefined(); // idle → clean send, no advisory
|
||||
expect(idleRes.warning).toContain(runtimeAdvisory);
|
||||
expect(idleRes.warning).not.toContain("mid-task");
|
||||
expect(idleRes.warning).not.toContain("producer-link:");
|
||||
expect(idleSpy).toHaveBeenCalled();
|
||||
|
||||
const runSpy = vi.fn(async () => ({ ok: true as const }));
|
||||
const running = makeTransport(mockTmux({ capturePaneContent: async () => "Working on task...\n⠋ Processing\nesc to interrupt", sendText: runSpy }));
|
||||
// Default (non-force) send on a running/busy pane now PROCEEDS with a non-blocking advisory
|
||||
// (was: ok:false mid_work). needs_input remains the ONLY hard refuse.
|
||||
// (was: ok:false mid_work). The existing prompt refusal remains in force.
|
||||
const def = await running.send("dev-impl@my-rig", "hi");
|
||||
expect(def.ok).toBe(true);
|
||||
expect(def.warning).toContain(runtimeAdvisory);
|
||||
expect(def.warning).toContain("mid-task");
|
||||
expect(def.warning).toContain("busy is advisory");
|
||||
// --force is now a no-op on this path (kept for back-compat) — still delivers.
|
||||
const forced = await running.send("dev-impl@my-rig", "hi", { force: true });
|
||||
expect(forced.ok).toBe(true);
|
||||
expect(forced.warning).toContain(runtimeAdvisory);
|
||||
expect(forced.warning).toContain("mid-task");
|
||||
expect(forced.warning).toContain("busy is advisory");
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ function tmuxWithPane(getPaneCommand: () => Promise<string | null>) {
|
||||
sendKeys,
|
||||
capturePaneContent: async () => "idle prompt\n❯ ",
|
||||
getPanePid: async () => null,
|
||||
listPanes: async () => [{ id: "%1" }],
|
||||
getPaneCommand,
|
||||
} as unknown as TmuxAdapter;
|
||||
return { tmux, sendText, sendKeys };
|
||||
@@ -68,12 +69,16 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
});
|
||||
|
||||
it.each([["claude-code", "zsh"], ["codex", "-bash"]])("%s seat showing %s: refused, nothing typed", async (runtime, shell) => {
|
||||
seat(runtime, "dev-impl@my-rig");
|
||||
const { node } = seat(runtime, "dev-impl@my-rig");
|
||||
const { tmux, sendText, sendKeys } = tmuxWithPane(async () => shell);
|
||||
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux }), "dev-impl@my-rig");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@my-rig", tmuxPane: "%1" });
|
||||
tmux.getPanePid = async () => 1135;
|
||||
const listProcesses = async () => [{ pid: 1135, ppid: 1, pgid: 1135, tpgid: 1135,
|
||||
executableName: shell.replace(/^-/, ""), command: shell, startedAt: "Thu Oct 1 11:00:00 2026" }];
|
||||
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux, listProcesses }), "dev-impl@my-rig");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(result.error).toContain(`${shell.replace(/^-/, "")} as the foreground command`);
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: runtime === "claude-code" ? "target_runtime_not_running" : "target_runtime_unverified" });
|
||||
expect(result.error).toContain(runtime === "claude-code" ? "idle shell" : `${shell.replace(/^-/, "")} as the foreground command`);
|
||||
expect(result.error).toContain("No text was sent");
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
@@ -221,28 +226,30 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
["conflicting identity arguments", () => claudeProcesses(`--resume other --session-id ${nativeToken}`)],
|
||||
["identity flag only in name value", () => claudeProcesses(`--name --session-id ${nativeToken}`)],
|
||||
];
|
||||
it.each(unprovedClaude)("#197 retains refusal for %s", async (_label, mutate) => {
|
||||
it.each(unprovedClaude)("#197 distinguishes missing evidence from a conflict: %s", async (label, mutate) => {
|
||||
const { transport, sendText, sendKeys } = wrappedClaude(vi.fn(async () => mutate(claudeProcesses())));
|
||||
const result = await transport.send("dev-check@my-rig", "existing review");
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(result.error).toContain("could not verify");
|
||||
expect(result.error).not.toMatch(/runtime is not running|Relaunch the seat|would run as shell commands/);
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
const conflict = ["wrong Claude identity", "ambiguous native children"].includes(label);
|
||||
expect(result.ok).toBe(!conflict);
|
||||
if (conflict) expect(result.reason).toBe("target_runtime_conflict");
|
||||
else expect(result.warning).toContain("without verified native identity");
|
||||
expect(sendText).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
});
|
||||
|
||||
it.each(["missing identity", "changed process", "changed bound pane", "process lookup failed"])("#197 refuses %s", async kind => {
|
||||
it.each(["missing identity", "process disappears from observation", "changed bound pane", "process lookup failed"])("#197 handles %s honestly", async kind => {
|
||||
const { transport, session, tmux, listProcesses, sendText, sendKeys } = wrappedClaude();
|
||||
if (kind === "missing identity") sessionRegistry.clearResumeToken(session.id);
|
||||
if (kind === "changed process") listProcesses.mockResolvedValueOnce(claudeProcesses()).mockResolvedValueOnce(claudeProcesses().slice(0, -1));
|
||||
if (kind === "process disappears from observation") listProcesses.mockResolvedValueOnce(claudeProcesses()).mockResolvedValueOnce(claudeProcesses().slice(0, -1));
|
||||
if (kind === "changed bound pane") tmux.getPanePid = async target => target === "%1" ? 999 : 1135;
|
||||
if (kind === "process lookup failed") listProcesses.mockRejectedValue(new Error("unavailable"));
|
||||
const result = await transport.send("dev-check@my-rig", "existing review");
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(result.error).toContain("could not verify");
|
||||
expect(result.error).not.toMatch(/runtime is not running|Relaunch the seat|would run as shell commands/);
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
const conflict = kind === "changed bound pane";
|
||||
expect(result.ok).toBe(!conflict);
|
||||
if (conflict) expect(result.reason).toBe("target_runtime_conflict");
|
||||
else expect(result.warning).toContain("without verified native identity");
|
||||
expect(sendText).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(conflict ? 0 : 1);
|
||||
});
|
||||
|
||||
it("#197 still refuses native approval after proving the Claude wrapper", async () => {
|
||||
@@ -272,7 +279,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
expect(sendKeys).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(stored.lastNudgeResult).toContain("failed:");
|
||||
expect(stored.lastNudgeResult).toContain("could not verify");
|
||||
expect(stored.lastNudgeResult).toContain("different Claude conversation");
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ function createAppWithUiDist(db: ReturnType<typeof createFullTestDb>, uiDistDir:
|
||||
podInstantiator: fullSetup.podInstantiator,
|
||||
podBundleSourceResolver: fullSetup.podBundleSourceResolver,
|
||||
uiDistDir,
|
||||
webUiEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { InboundRouter, type SlackEvent } from "../src/domain/gateway/slack/inbound.js";
|
||||
import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js";
|
||||
import { SeenStore, DeadLetterStore } from "../src/domain/gateway/slack/state-store.js";
|
||||
|
||||
it("keeps complete short and long human briefs durable while bounding summaries and deduplicating retries", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "openrig-slack-complete-"));
|
||||
const db = createDb(join(home, "state.sqlite"));
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const seen = new SeenStore(join(home, "seen.jsonl"));
|
||||
const deadLetter = new DeadLetterStore<SlackEvent>(join(home, "dead.jsonl"));
|
||||
const router = new InboundRouter({
|
||||
queue: makeQueuePorts(repo), seen, deadLetter, destination: "operator-agent@kernel",
|
||||
resolveSender: () => ({ admitted: true, source: "human-fixture@external" }),
|
||||
});
|
||||
try {
|
||||
for (const [index, text] of ["Short complete brief.", "x".repeat(1799) + "🧪\nKeep this final condition."].entries()) {
|
||||
const event = { type: "message", user: "U-FIXTURE", channel: "C-FIXTURE", text, ts: `1000.${index}` };
|
||||
const result = await router.route(event);
|
||||
expect(result.disposition).toBe("accepted");
|
||||
const row = repo.getById(result.qitemId!)!;
|
||||
expect(row.body).toBe(`${text}\n\n---\nSource: slack channel=C-FIXTURE user=U-FIXTURE ts=${event.ts}\nRouted by openrig slack-inbound. Default destination per config; re-route via queue as needed.`);
|
||||
expect(row.summary).toBe(`Founder via Slack: ${text.slice(0, 90)}`);
|
||||
expect(seen.load().has(event.ts)).toBe(true);
|
||||
expect((await router.route(event)).disposition).toBe("ignored");
|
||||
}
|
||||
expect(repo.list({ limit: 100 })).toHaveLength(2);
|
||||
expect(deadLetter.readAll()).toEqual([]);
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
// POSIX directory write protection requires an ordinary user. The fixture does
|
||||
// not pretend permissions fail on a root/Windows process where they do not.
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0).each(["onopen", "periodic"])(
|
||||
"%s retry logs a real storage fault, survives, and recovers exactly one delivery",
|
||||
(phase) => {
|
||||
const home = mkdtempSync(join(tmpdir(), "openrig-slack-retry-child-"));
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [
|
||||
fileURLToPath(new URL("./fixtures/slack-retry-storage-fault.mjs", import.meta.url)), phase, home,
|
||||
], { env: { ...process.env, HOME: home, TMUX: "" }, encoding: "utf8", timeout: 10000 });
|
||||
expect(result.error, result.stderr).toBeUndefined();
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout.trim())).toEqual({ phase, faults: 2, remaining: 0, rows: 1, state: "connected", requests: 1 });
|
||||
} finally {
|
||||
// A fatal baseline child cannot run its cleanup; retain ownership here.
|
||||
if (existsSync(join(home, "dead"))) chmodSync(join(home, "dead"), 0o700);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 15000,
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { serve, type ServerType } from "@hono/node-server";
|
||||
import http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import { registerTerminalWs } from "../src/routes/terminal-ws.js";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
|
||||
const TOKEN = "test-ws-route-token";
|
||||
const PORT = 19876;
|
||||
@@ -26,6 +27,8 @@ beforeAll(async () => {
|
||||
await next();
|
||||
});
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
// Production order (server.ts): the /api browser boundary runs before the terminal route.
|
||||
app.use("/api/*", browserBoundary({ webUiEnabled: true, bearerTokens: [TOKEN], warn: () => {} }));
|
||||
registerTerminalWs(app, upgradeWebSocket as never, { bearerToken: TOKEN });
|
||||
server = serve({ fetch: app.fetch, port: PORT, hostname: "127.0.0.1" });
|
||||
injectWebSocket(server);
|
||||
@@ -69,7 +72,7 @@ describe("terminal WebSocket route (production path)", () => {
|
||||
it("valid token WS upgrade does NOT return 404 (the QA blocker regression)", async () => {
|
||||
const result = await rawUpgrade(
|
||||
`/api/terminal/test-session?token=${TOKEN}`,
|
||||
{ Origin: "http://127.0.0.1" },
|
||||
{ Origin: `http://127.0.0.1:${PORT}` },
|
||||
);
|
||||
expect(result.statusCode, `expected non-404, got ${result.statusCode}: ${result.body}`).not.toBe(404);
|
||||
});
|
||||
@@ -77,7 +80,7 @@ describe("terminal WebSocket route (production path)", () => {
|
||||
it("missing token returns 401", async () => {
|
||||
const result = await rawUpgrade(
|
||||
"/api/terminal/test-session",
|
||||
{ Origin: "http://127.0.0.1" },
|
||||
{ Origin: `http://127.0.0.1:${PORT}` },
|
||||
);
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
@@ -93,7 +96,7 @@ describe("terminal WebSocket route (production path)", () => {
|
||||
it("wrong token returns 401", async () => {
|
||||
const result = await rawUpgrade(
|
||||
`/api/terminal/test-session?token=wrong`,
|
||||
{ Origin: "http://127.0.0.1" },
|
||||
{ Origin: `http://127.0.0.1:${PORT}` },
|
||||
);
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
@@ -1028,7 +1028,7 @@ describe("transport routes", () => {
|
||||
expect(String(data["warning"] ?? "")).toMatch(/no way of knowing who sent|no idea who sent/i);
|
||||
});
|
||||
|
||||
it("PROOF-4b: an attributed send's response carries no nag and delivers verbatim", async () => {
|
||||
it("PROOF-4b: an attributed send delivers verbatim with a runtime advisory but no unknown-sender notice", async () => {
|
||||
seedRig();
|
||||
const { app, sentTexts } = spyTransportApp();
|
||||
const res = await app.request("/api/transport/send", {
|
||||
@@ -1039,7 +1039,10 @@ describe("transport routes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(sentTexts[0]!.text).toBe("hello");
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data["warning"]).toBeUndefined();
|
||||
// Sender attribution does not establish the target's native identity.
|
||||
expect(data["warning"]).toContain("runtime: Claude runtime observation or older launch binding is unavailable; delivery proceeds without verified native identity.");
|
||||
expect(String(data["warning"])).not.toMatch(/no way of knowing who sent|no idea who sent/i);
|
||||
expect(String(data["warning"])).not.toMatch(/sign/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,9 @@ describe("config routes (User Settings v0)", () => {
|
||||
// + 1 OPR.0.5.9.5 (context.system_world)
|
||||
// + 2 OPR.0.5.10.7 context-pressure policy thresholds → 68 total.
|
||||
// + 1 S07 local-time preference.
|
||||
expect(Object.keys(body.settings).length).toBe(69);
|
||||
// + 1 ui.enabled (web UI and its terminal WebSocket; default off).
|
||||
expect(Object.keys(body.settings).length).toBe(70);
|
||||
expect(body.settings["ui.enabled"]).toMatchObject({ value: false, source: "default" });
|
||||
expect(body.settings["ui.timezone"]).toMatchObject({ value: "America/Los_Angeles", source: "default" });
|
||||
expect(body.settings["daemon.port"]?.source).toBe("default");
|
||||
expect(body.settings["health.context_pressure.warning_percent"]).toMatchObject({
|
||||
|
||||
@@ -112,6 +112,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
"health.context_pressure.critical_percent",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
|
||||
"ui.enabled",
|
||||
// OPR.0.4.0.1 — global live-terminal cap.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// The web UI and its terminal WebSocket are off unless `ui.enabled` is true. Off covers the UI's pages, assets and
|
||||
// SPA routes and the production WebSocket registration; every /api route, /healthz and the HTTP terminal routes the
|
||||
// CLI and TUI use answer exactly as they do with the UI on.
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import { createFullTestDb, createTestApp, mockTmuxAdapter, unavailableCmuxAdapter } from "./helpers/test-app.js";
|
||||
import { createApp, createAppWithWebSocket, WEB_UI_OFF_MESSAGE, type AppDeps } from "../src/server.js";
|
||||
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
const UI_INDEX_HTML = "<!doctype html><html><head><title>OpenRig UI</title></head><body><div id=\"root\"></div></body></html>";
|
||||
const TOKEN = "web-ui-off-token";
|
||||
|
||||
function tempUiDist(): string {
|
||||
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-ui-off-"));
|
||||
fs.mkdirSync(nodePath.join(dir, "assets"));
|
||||
fs.writeFileSync(nodePath.join(dir, "index.html"), UI_INDEX_HTML, "utf-8");
|
||||
fs.writeFileSync(nodePath.join(dir, "assets", "app.js"), "console.log('rig');", "utf-8");
|
||||
return dir;
|
||||
}
|
||||
|
||||
function appDeps(db: ReturnType<typeof createFullTestDb>, uiDistDir: string, extra: Partial<AppDeps>): AppDeps {
|
||||
const s = createTestApp(db) as unknown as Record<string, unknown>;
|
||||
return {
|
||||
rigRepo: s.rigRepo, sessionRegistry: s.sessionRegistry, eventBus: s.eventBus, nodeLauncher: s.nodeLauncher,
|
||||
tmuxAdapter: s.tmuxAdapter ?? mockTmuxAdapter(), cmuxAdapter: s.cmuxAdapter ?? unavailableCmuxAdapter(),
|
||||
snapshotCapture: s.snapshotCapture, snapshotRepo: s.snapshotRepo, restoreOrchestrator: s.restoreOrchestrator,
|
||||
rigSpecExporter: s.rigSpecExporter, rigSpecPreflight: s.rigSpecPreflight, rigInstantiator: s.rigInstantiator,
|
||||
packageRepo: s.packageRepo, installRepo: s.installRepo, installEngine: s.installEngine, installVerifier: s.installVerifier,
|
||||
bootstrapOrchestrator: s.bootstrapOrchestrator, bootstrapRepo: s.bootstrapRepo, discoveryCoordinator: s.discoveryCoordinator,
|
||||
discoveryRepo: s.discoveryRepo, claimService: s.claimService, psProjectionService: s.psProjectionService, upRouter: s.upRouter,
|
||||
teardownOrchestrator: s.teardownOrchestrator, podInstantiator: s.podInstantiator, podBundleSourceResolver: s.podBundleSourceResolver,
|
||||
uiDistDir,
|
||||
...extra,
|
||||
} as AppDeps;
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { while (cleanups.length) cleanups.pop()!(); });
|
||||
|
||||
function fixture(extra: Partial<AppDeps>) {
|
||||
const db = createFullTestDb();
|
||||
const dir = tempUiDist();
|
||||
cleanups.push(() => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
return appDeps(db, dir, extra);
|
||||
}
|
||||
|
||||
async function listen(deps: AppDeps): Promise<number> {
|
||||
const { app, injectWebSocket } = createAppWithWebSocket(deps);
|
||||
let server: ServerType | undefined;
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, (info) => resolve(info.port));
|
||||
});
|
||||
injectWebSocket(server);
|
||||
cleanups.push(() => server?.close());
|
||||
return port;
|
||||
}
|
||||
|
||||
function request(port: number, path: string, headers: Record<string, string> = {}): Promise<{ status: number; body: string; upgraded: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ hostname: "127.0.0.1", port, path, method: "GET", headers });
|
||||
req.on("response", (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body, upgraded: false }));
|
||||
});
|
||||
req.on("upgrade", (_res, socket) => { socket.destroy(); resolve({ status: 101, body: "", upgraded: true }); });
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Non-browser upgrade shape (no Origin), so 404 vs not-404 isolates route registration.
|
||||
const UPGRADE = {
|
||||
Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Version": "13",
|
||||
"Sec-WebSocket-Key": Buffer.from("web-ui-off-key16").toString("base64"),
|
||||
};
|
||||
|
||||
describe("web UI default off (ui.enabled)", () => {
|
||||
it("defaults to false and turns on through the existing setting", () => {
|
||||
const prior = process.env.OPENRIG_UI_ENABLED;
|
||||
try {
|
||||
delete process.env.OPENRIG_UI_ENABLED;
|
||||
expect(new SettingsStore().resolveOne("ui.enabled")).toMatchObject({ value: false, source: "default" });
|
||||
process.env.OPENRIG_UI_ENABLED = "true";
|
||||
expect(new SettingsStore().resolveOne("ui.enabled").value).toBe(true);
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env.OPENRIG_UI_ENABLED; else process.env.OPENRIG_UI_ENABLED = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it("serves no UI page, asset or SPA route by default, and names the setting", async () => {
|
||||
const app = createApp(fixture({ terminalBearerToken: TOKEN }));
|
||||
for (const path of ["/", "/index.html", "/specs", "/rigs/rig-1/nodes/dev.impl", "/assets/app.js", "/apiX"]) {
|
||||
const res = await app.request(path);
|
||||
const body = await res.text();
|
||||
expect(res.status, path).toBe(404);
|
||||
expect(res.headers.get("x-openrig-web-ui"), path).toBe("off");
|
||||
expect(body, path).toBe(WEB_UI_OFF_MESSAGE);
|
||||
expect(body, path).toContain("rig config set ui.enabled true");
|
||||
expect(body, path).not.toContain("OpenRig UI");
|
||||
expect(body, path).not.toContain(TOKEN);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the UI again when enabled", async () => {
|
||||
const app = createApp(fixture({ webUiEnabled: true }));
|
||||
const res = await app.request("/specs");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe(UI_INDEX_HTML);
|
||||
expect((await app.request("/assets/app.js")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("answers health, API and HTTP terminal routes the same with the UI off and on", async () => {
|
||||
const off = createApp(fixture({ terminalBearerToken: TOKEN }));
|
||||
const on = createApp(fixture({ terminalBearerToken: TOKEN, webUiEnabled: true }));
|
||||
for (const path of ["/healthz", "/api/unknown", "/api/config", "/api/terminal/views", "/api/terminal/status", "/api/terminal/preview?view=x"]) {
|
||||
const a = await off.request(path), b = await on.request(path);
|
||||
expect(a.status, path).toBe(b.status);
|
||||
expect(a.headers.get("x-openrig-web-ui"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the production terminal WebSocket only when enabled; HTTP terminal auth is unchanged", async () => {
|
||||
const offPort = await listen(fixture({ terminalBearerToken: TOKEN }));
|
||||
const onPort = await listen(fixture({ terminalBearerToken: TOKEN, webUiEnabled: true }));
|
||||
|
||||
const offUpgrade = await request(offPort, `/api/terminal/seat-1?token=${TOKEN}`, UPGRADE);
|
||||
expect(offUpgrade.upgraded).toBe(false);
|
||||
expect(offUpgrade.status).toBe(404);
|
||||
const onUpgrade = await request(onPort, `/api/terminal/seat-1?token=${TOKEN}`, UPGRADE);
|
||||
expect(onUpgrade.status, onUpgrade.body).not.toBe(404);
|
||||
// A browser upgrade (own-UI Origin) is refused by the /api browser boundary while the UI is off.
|
||||
const offBrowser = await request(offPort, `/api/terminal/seat-1?token=${TOKEN}`, { ...UPGRADE, Origin: `http://127.0.0.1:${offPort}` });
|
||||
expect(offBrowser.upgraded).toBe(false);
|
||||
expect(offBrowser.status).toBe(403);
|
||||
|
||||
// The guard in front of the HTTP terminal routes stays the same either way.
|
||||
for (const [path, headers] of [
|
||||
["/api/terminal/views", {}],
|
||||
["/api/terminal/views", { Authorization: `Bearer ${TOKEN}` }],
|
||||
["/api/terminal/status", {}],
|
||||
[`/api/terminal/status?token=${TOKEN}`, {}],
|
||||
] as const) {
|
||||
const a = await request(offPort, path, headers), b = await request(onPort, path, headers);
|
||||
expect(a.status, `${path} ${JSON.stringify(headers)}`).toBe(b.status);
|
||||
}
|
||||
expect((await request(offPort, "/api/terminal/views")).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,7 @@ const LABELS: Record<string, string> = {
|
||||
"ui.preview.refresh_interval_seconds": "Preview refresh", "ui.preview.max_pins": "Preview pins",
|
||||
"ui.preview.default_lines": "Preview lines", "context.system_world": "System world",
|
||||
"onboarding.default_pack.enabled": "Default onboarding pack",
|
||||
"ui.enabled": "Web UI",
|
||||
"health.context_pressure.warning_percent": "Context warning threshold",
|
||||
"health.context_pressure.critical_percent": "Context critical threshold",
|
||||
"recovery.auto_drive_provider_prompts": "Drive provider prompts",
|
||||
|
||||
@@ -97,7 +97,11 @@ export class DaemonClient {
|
||||
|
||||
private async get(route: string, fetchImpl = this.fetchImpl): Promise<unknown> {
|
||||
const res = await fetchImpl(`${this.baseUrl}${route}`, { headers: this.headers, signal: AbortSignal.timeout(5_000) });
|
||||
if (!res.ok) throw new Error(`daemon read failed: GET ${route} → ${res.status}`);
|
||||
if (!res.ok) {
|
||||
const parsed = await res.json().catch(() => null);
|
||||
const detail = parsed && typeof parsed === "object" && "error" in parsed ? ` — ${(parsed as { error: unknown }).error}` : "";
|
||||
throw new Error(`daemon read failed: GET ${route} → ${res.status}${detail}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// The real TUI daemon client read path, over real HTTP, carries the browser boundary's
|
||||
// refusal sentence (previously it reported only the status).
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Hono } from "hono";
|
||||
import { DaemonClient } from "../src/daemon-client.js";
|
||||
import { browserBoundary } from "../../daemon/src/middleware/browser-boundary.js";
|
||||
|
||||
let server: http.Server;
|
||||
let port = 0;
|
||||
beforeAll(async () => {
|
||||
const daemon = new Hono();
|
||||
daemon.use("/api/*", browserBoundary({ webUiEnabled: false, bearerTokens: [], warn: () => {} }));
|
||||
daemon.all("/api/*", (c) => c.json({ ok: true }));
|
||||
const body = await (await daemon.request("/api/health", { headers: { Host: "unlisted.example:7433" } })).text();
|
||||
server = http.createServer((_q, r) => { r.writeHead(403, { "content-type": "application/json" }); r.end(body); });
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterAll(async () => { await new Promise<void>((r) => server.close(() => r())); });
|
||||
|
||||
it("a refused read names the remedy", async () => {
|
||||
const client = new DaemonClient({ baseUrl: `http://127.0.0.1:${port}` } as never);
|
||||
const message = await client.healthFindings(5).then(() => "", (e: Error) => e.message);
|
||||
expect(message).toContain("403");
|
||||
expect(message).toContain("OPENRIG_ALLOWED_HOSTS");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4-rc.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"core/openrig-architect/SKILL.md": "0c3f1b7ff5bf185c9da963905229e951ffe1fb6d45a3cc9247cb747fc1d9a59d",
|
||||
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
|
||||
"core/openrig-herdr/SKILL.md": "37508a02f50bb69ee97c22c37ccf70aa8e37dc0784061eb2a5ad813571eebd1a",
|
||||
"core/openrig-skills/SKILL.md": "fee88b59cf454156ab7d04bae9ae01ce6bba01a89643f3c39fea7425df247457",
|
||||
"core/openrig-skills/SKILL.md": "0c700beac8210e0ad37021d34b0f9e2f457161a8bc9527e5d0a2e27b046bc7c1",
|
||||
"core/openrig-software-factory/SKILL.md": "33930e72341661eaaa9d3c5ac945297926f430d643b6f279fcb7f693ac30b7f7",
|
||||
"core/openrig-software-factory/references/worked-example.md": "ff5485a0e84de43188cf791cc96258abe038d035d439997f21865e33a1916bc5",
|
||||
"core/openrig-upgrade/SKILL.md": "c3555c66abe0426e2990aec024849f644cf43320a65b8f1211e96d3b234f6e83",
|
||||
@@ -63,7 +63,7 @@
|
||||
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "5584cff4ab36cd843e14c81bd201e4a2f8e498e995abacc4f81ec6c1fc556b10",
|
||||
"claude-compaction-restore/templates/compact-instruction.md": "682c778464517b831902ddc2678c055545899b4751a1d32391ce965d5eb7061d",
|
||||
"claude-compaction-restore/templates/post-compact-restore-instruction.md": "10a5a7e5e052f3aa000a4209f386dc4eb90a3c2bba449ca685f124714f59e47a",
|
||||
"delegating-work/SKILL.md": "9809781b3ee8d0ffcb411af3ebd3b48257f9dbd9eac652350c0e30d000c0d236",
|
||||
"delegating-work/SKILL.md": "54c061d1b28435266952d431765b40f4170581279decfffd3b10839848cd65e4",
|
||||
"forming-an-openrig-mental-model/SKILL.md": "cff1ca1640fec65b180539a2c6c3c9044d73aa02f9d13d0d24763446ac974da0",
|
||||
"loading-addressable-markdown/SKILL.md": "104dc6a02ae6e2d0eff519387c6839cb09803945b9ee35ec3498af8feaaa8d1e",
|
||||
"loading-addressable-markdown/scripts/resolve-markdown.mjs": "ef113af7bb297058a8c23a82a4c171232ae8c2d6b1705a89a80140000ad3bca0",
|
||||
@@ -80,7 +80,7 @@
|
||||
"openrig-operating-model/templates/PLAYBOOK.md": "ff6f9dc8caab06dc7b9ae21977310ddefa23e925453bd54629ce0a669403bfb0",
|
||||
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
|
||||
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
|
||||
"openrig-skills/SKILL.md": "fee88b59cf454156ab7d04bae9ae01ce6bba01a89643f3c39fea7425df247457",
|
||||
"openrig-skills/SKILL.md": "0c700beac8210e0ad37021d34b0f9e2f457161a8bc9527e5d0a2e27b046bc7c1",
|
||||
"openrig-user/SKILL.md": "ed43062826294ad6879c125197b7d5752396372ac80b25a2f36e89afd617ae33",
|
||||
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
|
||||
"orienting-to-an-inherited-seat/SKILL.md": "4d2bb1f0dd522b0b956bf2191ce2121c36590936e217121f781525f77f8229a9",
|
||||
@@ -106,7 +106,7 @@
|
||||
"core/openrig-architect/SKILL.md": "0c3f1b7ff5bf185c9da963905229e951ffe1fb6d45a3cc9247cb747fc1d9a59d",
|
||||
"core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc",
|
||||
"core/openrig-herdr/SKILL.md": "37508a02f50bb69ee97c22c37ccf70aa8e37dc0784061eb2a5ad813571eebd1a",
|
||||
"core/openrig-skills/SKILL.md": "fee88b59cf454156ab7d04bae9ae01ce6bba01a89643f3c39fea7425df247457",
|
||||
"core/openrig-skills/SKILL.md": "0c700beac8210e0ad37021d34b0f9e2f457161a8bc9527e5d0a2e27b046bc7c1",
|
||||
"core/openrig-software-factory/SKILL.md": "33930e72341661eaaa9d3c5ac945297926f430d643b6f279fcb7f693ac30b7f7",
|
||||
"core/openrig-software-factory/references/worked-example.md": "ff5485a0e84de43188cf791cc96258abe038d035d439997f21865e33a1916bc5",
|
||||
"core/openrig-upgrade/SKILL.md": "c3555c66abe0426e2990aec024849f644cf43320a65b8f1211e96d3b234f6e83",
|
||||
|
||||
@@ -122,4 +122,5 @@ and a useful report to the OpenRig team if you are still stuck. If `rig` won't r
|
||||
|
||||
## Need more than what ships here?
|
||||
|
||||
This index covers the **shipped** surface. A dev host carries far more (factory, architecture, PM-craft, studio skills) reached through the host's own routers/codemaps — if you're on a builder host and need something not listed above, that deeper routing is the next hop, not a wall. (Host-scale routing is the subject of the context-routing architecture doc; at product scale, this one file is the whole map.)
|
||||
This index covers the **shipped** surface. Your project, rig or machine may add its own skills and routers. Check the
|
||||
skills your working directory loads before concluding that something doesn't exist.
|
||||
Reference in new issue
Block a user