mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d7970fd50 | ||
|
|
afde814f5b | ||
|
|
cc2c37dc2f | ||
|
|
cc02aef23f | ||
|
|
657bc01368 | ||
|
|
9b9cf9ec4c | ||
|
|
7810d5120a | ||
|
|
6678cef5a1 | ||
|
|
3dca5ee54b | ||
|
|
e6bfdce242 | ||
|
|
d3ad0d1995 | ||
|
|
56a3efeaa1 | ||
|
|
f45c64f642 | ||
|
|
8a8a3633ca | ||
|
|
c149053c5e | ||
|
|
87fc4fa2d5 | ||
|
|
f056f06a34 | ||
|
|
da2297e13a | ||
|
|
812e1e555f | ||
|
|
b2679174e5 | ||
|
|
c0a322950c | ||
|
|
4658c48842 | ||
|
|
c849d54f5f | ||
|
|
b3b9bdc09d | ||
|
|
757b204348 | ||
|
|
ed0f323092 | ||
|
|
bcc0010de9 | ||
|
|
5177ec8913 | ||
|
|
5b344e0578 | ||
|
|
b43498d7da |
No files matched your search
@@ -12,6 +12,10 @@
|
||||
|
||||
<!-- Design choices, edge cases you did not cover, places a reviewer should look hardest. Empty is a fine answer. -->
|
||||
|
||||
## If this is security-related
|
||||
|
||||
<!-- Exploitable? Report it privately first (SECURITY.md). Otherwise: how does this go wrong for someone using OpenRig as designed (your own machine or a trusted private network), with evidence? Who causes it, and how do they reach the install? What does the change cost everyone else? See CONTRIBUTING.md. -->
|
||||
|
||||
- [ ] One concern per PR; no version bump; no `CHANGELOG.md` edit
|
||||
- [ ] Tests added or updated where the change is testable
|
||||
- [ ] I listed the checks I ran, their results, and any checks I could not run
|
||||
@@ -17,6 +17,27 @@ change in with the least friction on both sides.
|
||||
- **Questions:** use [Discussions › Q&A](https://github.com/mvschwarz/openrig/discussions/categories/q-a),
|
||||
not an issue.
|
||||
|
||||
## Before a security or integration PR
|
||||
|
||||
OpenRig connects your coding agents to each other: shared context, messaging, coordination and long-running seats.
|
||||
It's designed for your own machine or a trusted private network, for you and people you trust, not for the open
|
||||
internet. It doesn't try to act for you in the outside world; your agents already have tools for that.
|
||||
|
||||
**Security-related PRs.** If you've found something exploitable, report it privately first (see
|
||||
[SECURITY.md](SECURITY.md)) rather than in a public PR. For hardening changes, tell us in the PR description:
|
||||
|
||||
- the scenario: how this goes wrong for someone using OpenRig as it's designed to be used, and the evidence you have
|
||||
- who or what causes it, and how they reach the install
|
||||
- what the change costs everyone else: a refusal, an extra step, a new setting
|
||||
|
||||
We look at the finding and the fix separately. We may agree with a finding and fix it differently, or decide the fix
|
||||
costs more than it protects. If the answers are missing we'll ask once, and close the PR with thanks if they don't
|
||||
come.
|
||||
|
||||
**Integrations with other tools or projects.** Open an issue or an Ideas Discussion and wait for a maintainer's yes on
|
||||
scope before you build it; an issue on its own isn't a yes. Often the best home for an integration is your own
|
||||
repository, and we're happy to link to it.
|
||||
|
||||
## Setting up
|
||||
|
||||
Node `^22 || ^24` and a working `tmux` are required. Then:
|
||||
|
||||
@@ -39,6 +39,10 @@ could do. A proof of concept is welcome; a working exploit against a third party
|
||||
|
||||
## Scope notes
|
||||
|
||||
- OpenRig is designed for your own machine or a trusted private network, for you and people you trust, not for the
|
||||
open internet. We assess reports by their practical impact on a user in that setup. Exposing an install to the open
|
||||
internet isn't a supported deployment. OpenRig's own gateway integrations with outside services, such as Slack, and
|
||||
anything that lets an untrusted party reach the daemon or an agent are in scope.
|
||||
- OpenRig assumes the machine and the accounts it runs under are trusted by their owner. Reports
|
||||
that require a hostile local user with the same account are still welcome but are unlikely to be
|
||||
treated as high severity.
|
||||
|
||||
@@ -77,7 +77,7 @@ export class DaemonTimeoutError extends DaemonConnectionError {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bad-response: the daemon replied, but the body could not be read as JSON
|
||||
* Bad-response: the daemon replied, but the body could not be read or parsed as JSON
|
||||
* (truncated / unparseable / non-JSON — a real symptom under daemon saturation).
|
||||
* DISTINCT from a stopped or unreachable daemon: the request WAS delivered and
|
||||
* the outcome is unknown, so this must never render as daemon-not-running.
|
||||
@@ -216,7 +216,7 @@ export class DaemonClient {
|
||||
}, options);
|
||||
}
|
||||
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions, consumeResponse?: (response: Response) => Promise<void>): Promise<Response> {
|
||||
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
||||
if (options?.headers) {
|
||||
init = { ...init, headers: { ...(init.headers as Record<string, string> ?? {}), ...options.headers } };
|
||||
@@ -226,6 +226,7 @@ export class DaemonClient {
|
||||
// Known remote or unproved direct endpoints carry origin; proven local requests remain bare.
|
||||
this.identity ??= this.identityHeaders();
|
||||
init = { ...init, headers: { ...(init.headers as Record<string, string> ?? {}), ...await this.identity } };
|
||||
let responseStatus: number | undefined;
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
this.fetchImpl,
|
||||
@@ -233,6 +234,10 @@ export class DaemonClient {
|
||||
init,
|
||||
{
|
||||
timeoutMs,
|
||||
consumeResponse: consumeResponse ? async (response) => {
|
||||
responseStatus = response.status;
|
||||
await consumeResponse(response);
|
||||
} : undefined,
|
||||
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${timeoutMs}ms`,
|
||||
},
|
||||
);
|
||||
@@ -249,18 +254,21 @@ export class DaemonClient {
|
||||
if (err instanceof FetchTimeoutError) {
|
||||
throw new DaemonTimeoutError(`The OpenRig daemon at ${this.baseUrl} did not respond in time: ${msg}`);
|
||||
}
|
||||
// Headers prove that the daemon received the request, but not that a write
|
||||
// finished. A dropped body must preserve unknown-outcome guidance.
|
||||
if (responseStatus !== undefined) throw new DaemonResponseError(responseStatus, "");
|
||||
throw new DaemonConnectionError(`Cannot connect to the OpenRig daemon at ${this.baseUrl}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
const res = await this.fetch(path, init, options);
|
||||
let text = "";
|
||||
const res = await this.fetch(path, init, options, async (response) => { text = await response.text(); });
|
||||
// Read the raw body once, THEN parse — so a truncated / unparseable response
|
||||
// (a real symptom under daemon saturation) surfaces as a typed
|
||||
// DaemonResponseError carrying the status + a bounded snippet, instead of a
|
||||
// raw SyntaxError bubbling to a cryptic (json) or silent (human) CLI exit.
|
||||
// A well-formed non-2xx body still parses and returns {status,data}; 204 has none.
|
||||
const text = await res.text();
|
||||
if (res.status === 204) return { status: res.status, data: undefined as T };
|
||||
try {
|
||||
return { status: res.status, data: JSON.parse(text) as T };
|
||||
@@ -270,8 +278,8 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
const res = await this.fetch(path, init, options);
|
||||
const data = await res.text();
|
||||
let data = "";
|
||||
const res = await this.fetch(path, init, options, async (response) => { data = await response.text(); });
|
||||
return { status: res.status, data };
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
|
||||
}
|
||||
|
||||
const hasNode = !!opts.node;
|
||||
const hasPodFields = !!opts.pod || !!opts.member || !!opts.runtime;
|
||||
const hasPodFields = !!opts.pod || !!opts.member;
|
||||
if (hasNode && hasPodFields) {
|
||||
console.error("Specify either --node or --pod + --member + --runtime, not both.");
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -32,6 +32,22 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
|
||||
};
|
||||
}
|
||||
|
||||
type ExecFile = (file: string, args: string[], options: { encoding: "utf-8" }) => string;
|
||||
|
||||
// Windows has no ps(1) and no zombie state, so the signal probe alone decides liveness there.
|
||||
export function readProcessState(
|
||||
pid: number,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run: ExecFile = execFileSync,
|
||||
): string | null {
|
||||
if (platform === "win32") return "R";
|
||||
try {
|
||||
return run("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf-8" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function realDeps(): LifecycleDeps {
|
||||
const isProcessAlive = createIsProcessAlive({
|
||||
signalCheck: (pid) => {
|
||||
@@ -42,13 +58,7 @@ export function realDeps(): LifecycleDeps {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
readProcessState: (pid) => {
|
||||
try {
|
||||
return execFileSync("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf-8" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
readProcessState: (pid) => readProcessState(pid),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -58,6 +58,12 @@ Examples:
|
||||
}
|
||||
|
||||
const receipt = res.data["receipt"] as Record<string, unknown> | null;
|
||||
const probeStatus = res.data["probeStatus"];
|
||||
if (probeStatus === "stale" || probeStatus === "no_orchestrator") {
|
||||
const probeError = res.data["probeError"];
|
||||
console.error(`Service status is ${probeStatus === "stale" ? "stale" : "unverified"} (probe: ${probeStatus}); ${receipt ? "showing the saved receipt" : "no saved receipt is available"}.${typeof probeError === "string" && probeError ? ` ${probeError}` : ""}`);
|
||||
}
|
||||
|
||||
if (!receipt) {
|
||||
console.log("Services configured but no receipt available yet.");
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { DaemonClient, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -477,10 +477,26 @@ Examples:
|
||||
const daemon = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(daemon)) return;
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
`/api/seat/${path}/${encodeURIComponent(seat)}`,
|
||||
body,
|
||||
);
|
||||
let res;
|
||||
try {
|
||||
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);
|
||||
} catch (err) {
|
||||
if (path !== "launch" || !(err instanceof DaemonTimeoutError)) throw err;
|
||||
const error = {
|
||||
ok: false as const,
|
||||
code: "launch_outcome_unknown",
|
||||
status: "unknown",
|
||||
message: "The CLI timed out waiting for the daemon; the launch may still be in progress.",
|
||||
guidance: `Check the outcome before retrying: rig seat status ${seat}`,
|
||||
};
|
||||
if (opts.json) console.log(JSON.stringify(error, null, 2));
|
||||
else printSeatError(error, error.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseDocument } from "yaml";
|
||||
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
upsertCmuxSocketControlMode,
|
||||
} from "../cmux-config.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { resolveCodexHome } from "../lib/codex-auth.js";
|
||||
|
||||
export interface SetupStep {
|
||||
id: string;
|
||||
@@ -56,6 +59,43 @@ export interface SetupDeps {
|
||||
exists: (path: string) => boolean;
|
||||
mkdirp?: (path: string) => void;
|
||||
platform?: NodeJS.Platform;
|
||||
/** Environment for CODEX_HOME and provider credential checks (default process.env). */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/** Issue #194 — how the Codex provider selected in `$CODEX_HOME/config.toml`
|
||||
* authenticates. Only an explicit provider entry with
|
||||
* `requires_openai_auth = false` and an `env_key` uses its credential
|
||||
* variable; every unresolved case keeps the OpenAI login check. Other Codex
|
||||
* config layers are not resolved here. The daemon's kernel probe
|
||||
* (`selectCodexProviderAuth` in kernel-boot.ts) carries the same rule. */
|
||||
export type CodexProviderAuth =
|
||||
| { kind: "openai-login"; unresolved?: string }
|
||||
| { kind: "env-key"; providerId: string; envKey: string };
|
||||
|
||||
export function selectCodexProviderAuth(configToml: string | null): CodexProviderAuth {
|
||||
if (configToml === null) return { kind: "openai-login" };
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = parseToml(configToml) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { kind: "openai-login", unresolved: "config.toml could not be parsed" };
|
||||
}
|
||||
if (Object.hasOwn(config, "profile")) {
|
||||
return { kind: "openai-login", unresolved: "config.toml selects a legacy profile, which is not resolved here" };
|
||||
}
|
||||
const providerId = config["model_provider"];
|
||||
const providers = config["model_providers"];
|
||||
if (typeof providerId !== "string" || !providers || typeof providers !== "object" || !Object.hasOwn(providers, providerId)) {
|
||||
return { kind: "openai-login" };
|
||||
}
|
||||
const entry = (providers as Record<string, unknown>)[providerId];
|
||||
if (!entry || typeof entry !== "object") return { kind: "openai-login" };
|
||||
const { requires_openai_auth: requiresOpenAiAuth, env_key: envKey } = entry as Record<string, unknown>;
|
||||
if (requiresOpenAiAuth !== false || typeof envKey !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) {
|
||||
return { kind: "openai-login" };
|
||||
}
|
||||
return { kind: "env-key", providerId, envKey };
|
||||
}
|
||||
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
||||
@@ -562,17 +602,38 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
|
||||
}
|
||||
|
||||
if (codexInstalled) {
|
||||
try {
|
||||
deps.exec("codex login status");
|
||||
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "fail",
|
||||
message: `Codex is installed but not ready to launch: ${(err as Error).message}`,
|
||||
reason: "Codex seats cannot launch until the Codex CLI is logged in and usable.",
|
||||
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
const env = deps.env ?? process.env;
|
||||
const codexAuth = selectCodexProviderAuth(deps.readFile(path.join(resolveCodexHome(env).codexHome, "config.toml")));
|
||||
if (codexAuth.kind === "env-key") {
|
||||
if (env[codexAuth.envKey]?.trim()) {
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "pass",
|
||||
message: `Codex provider "${codexAuth.providerId}" does not use an OpenAI login, and its credential variable ${codexAuth.envKey} is set. This confirms a local credential is available, not that the provider accepts it or that managed seats receive it.`,
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "fail",
|
||||
message: `Codex provider "${codexAuth.providerId}" needs ${codexAuth.envKey}, which is not set in this environment.`,
|
||||
reason: "Codex seats using this provider cannot authenticate without that variable.",
|
||||
fixHint: `Export ${codexAuth.envKey} in the environment that runs rig setup and the OpenRig daemon, then rerun \`rig setup\`.`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
deps.exec("codex login status");
|
||||
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
|
||||
} catch (err) {
|
||||
const unresolved = codexAuth.unresolved ? ` (${codexAuth.unresolved}, so the OpenAI login was checked)` : "";
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "fail",
|
||||
message: `Codex is installed but not ready to launch${unresolved}: ${(err as Error).message}`,
|
||||
reason: "Codex seats cannot launch until the Codex CLI is logged in and usable.",
|
||||
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
@@ -677,7 +738,7 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
|
||||
|
||||
function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
|
||||
const platform = setupDeps.platform ?? process.platform;
|
||||
const baseDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
|
||||
const baseDir = path.dirname(path.dirname(fileURLToPath(new URL(import.meta.url))));
|
||||
return {
|
||||
exists: setupDeps.exists,
|
||||
baseDir,
|
||||
|
||||
@@ -451,6 +451,10 @@ Examples:
|
||||
for (const node of nodes ?? []) {
|
||||
console.error(` ${node.logicalId}${node.sessionName ? ` (${node.sessionName})` : ""}: ${node.reason}`);
|
||||
}
|
||||
// #141: the rig is kept on this path, so its warnings (e.g. the archived earlier generation) still apply.
|
||||
for (const w of (res.data["warnings"] as string[]) ?? []) {
|
||||
console.error(` warning: ${w}`);
|
||||
}
|
||||
} else if (code === "cycle_error") {
|
||||
console.error("Cycle detected in rig topology. Check edge definitions for circular dependencies.");
|
||||
} else if (code === "validation_failed") {
|
||||
@@ -464,11 +468,12 @@ Examples:
|
||||
} else if (code === "invalid_topology_manifest") {
|
||||
const errors = (res.data["errors"] as string[]) ?? [];
|
||||
console.error(`Topology manifest invalid:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: the manifest key set is CLOSED — rigs[]{source, host?} plus optional concurrency.`);
|
||||
} else if (code === "rig_name_running") {
|
||||
} else if (code === "rig_name_running" || code === "generation_unconfirmed") {
|
||||
// S5b final-fix F1 (OPR.0.5.4.11): the guard's teaching refusal is
|
||||
// self-describing (running rig identity, what was checked,
|
||||
// nothing-created, alternatives) — render it verbatim, never the
|
||||
// generic unknown-error/validate-your-spec fallback.
|
||||
// generic unknown-error/validate-your-spec fallback. #141's
|
||||
// generation_unconfirmed refusal is self-describing the same way.
|
||||
const teaching = String(res.data["error"] ?? ((res.data["errors"] as string[]) ?? [])[0] ?? "A rig with this name is already running.");
|
||||
console.error(teaching);
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { HostEntry } from "./host-registry.js";
|
||||
|
||||
/**
|
||||
@@ -114,14 +115,16 @@ export async function runCrossHostCommand(
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk);
|
||||
});
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,6 +136,8 @@ export async function runCrossHostCommand(
|
||||
});
|
||||
});
|
||||
|
||||
stdout += stdoutDecoder.end();
|
||||
stderr += stderrDecoder.end();
|
||||
return classifyResult(exitCode, stdout, stderr);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export class FetchTimeoutError extends Error {
|
||||
export interface FetchWithTimeoutOptions {
|
||||
timeoutMs: number;
|
||||
timeoutMessage: string;
|
||||
/** Consume finite response bodies before releasing the request deadline. */
|
||||
consumeResponse?: (response: Response) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(
|
||||
@@ -38,7 +40,9 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchImpl(url, { ...init, signal: controller.signal });
|
||||
const response = await fetchImpl(url, { ...init, signal: controller.signal });
|
||||
await options.consumeResponse?.(response);
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err instanceof FetchTimeoutError) throw err;
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { loadHostRegistry, resolveHost, type SshHostEntry } from "./../host-registry.js";
|
||||
import { looksLikePermissionGate } from "./../cross-host-executor.js";
|
||||
@@ -131,7 +132,10 @@ export function checkLocalPath(raw: string): PathCheck {
|
||||
error: `refused: '${raw}' resolves into the active OPENRIG_HOME (${activeOpenRigHome}) — live OpenRig state is not a copy source/target in v0 (crash-safety). This is the FR-4 default-deny wall (a short closed list; extension requires a ruling).`,
|
||||
};
|
||||
}
|
||||
return { ok: true, normalizedPath: resolved };
|
||||
// rsync distinguishes a directory from its contents by the source's trailing separator.
|
||||
const normalizedPath = raw.endsWith(path.sep) && !resolved.endsWith(path.sep)
|
||||
? resolved + path.sep : resolved;
|
||||
return { ok: true, normalizedPath };
|
||||
}
|
||||
|
||||
/** Remote side: posix-normalize, then the wall — absolute-only (arch Q5),
|
||||
@@ -328,12 +332,14 @@ export async function runFileCopy(plan: CopyPlan, deps: { spawn?: SpawnFn } = {}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
let spawnFailed: NodeJS.ErrnoException | null = null;
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
stdout += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk);
|
||||
});
|
||||
|
||||
const exitCode: number | null = await new Promise((resolve) => {
|
||||
@@ -344,6 +350,8 @@ export async function runFileCopy(plan: CopyPlan, deps: { spawn?: SpawnFn } = {}
|
||||
child.on("close", (code: number | null) => resolve(code));
|
||||
});
|
||||
|
||||
stdout += stdoutDecoder.end();
|
||||
stderr += stderrDecoder.end();
|
||||
if (spawnFailed !== null && (spawnFailed as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -60,6 +60,7 @@ function runningDeps(port: number): StatusDeps {
|
||||
describe("Attach CLI", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let receivedBody: Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer(async (req, res) => {
|
||||
@@ -68,6 +69,7 @@ describe("Attach CLI", () => {
|
||||
|
||||
if (req.url === "/api/rigs/rig-1/attach-self" && req.method === "POST") {
|
||||
const parsed = body ? JSON.parse(body) : {};
|
||||
receivedBody = parsed;
|
||||
const logicalId = parsed.logicalId ?? `${parsed.podNamespace}.${parsed.memberName}`;
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
@@ -119,6 +121,16 @@ describe("Attach CLI", () => {
|
||||
expect(output).toContain("inbound tmux transport unavailable");
|
||||
});
|
||||
|
||||
it("attach --self --node accepts the advertised runtime guard", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--node", "orch1.lead", "--runtime", "codex"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n")).toContain("Attached this shell to node orch1.lead");
|
||||
expect(receivedBody).toMatchObject({ logicalId: "orch1.lead", runtime: "codex" });
|
||||
});
|
||||
|
||||
it("attach --self --print-env prints shell exports only", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--node", "orch1.lead", "--print-env"]);
|
||||
@@ -140,6 +152,14 @@ describe("Attach CLI", () => {
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["--pod", "--member"])("attach rejects node mode combined with %s", async (flag) => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--node", "orch1.lead", "--runtime", "codex", flag, "orch1"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("Specify either");
|
||||
});
|
||||
|
||||
it("attach rejects incomplete pod mode before calling the daemon", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "attach", "--self", "--rig", "rig-1", "--pod", "orch1", "--member", "lead"]);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { Command } from "commander";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
import { DaemonClient, DaemonTimeoutError } from "../src/client.js";
|
||||
|
||||
let server: http.Server | undefined;
|
||||
afterEach(async () => {
|
||||
if (!server) return;
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
async function slowBodyServer(delayMs: number) {
|
||||
let requests = 0;
|
||||
let closedBeforeEnd = 0;
|
||||
let response: http.ServerResponse | undefined;
|
||||
let markHeadersWritten: () => void;
|
||||
const headersWritten = new Promise<void>((resolve) => { markHeadersWritten = resolve; });
|
||||
server = http.createServer(async (req, res) => {
|
||||
for await (const _chunk of req) { /* consume the actual request body */ }
|
||||
requests++;
|
||||
response = res;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.write('{"ok":'); // fetch resolves headers, while body remains incomplete
|
||||
markHeadersWritten();
|
||||
const timer = setTimeout(() => res.end("true}"), delayMs);
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) closedBeforeEnd++;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
|
||||
return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
requests: () => requests, closedBeforeEnd: () => closedBeforeEnd,
|
||||
headersWritten, dropBody: () => response!.destroy() };
|
||||
}
|
||||
|
||||
function observedFetch() {
|
||||
let markHeadersReceived: () => void;
|
||||
const headersReceived = new Promise<void>((resolve) => { markHeadersReceived = resolve; });
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
markHeadersReceived();
|
||||
return response;
|
||||
};
|
||||
return { fetchImpl, headersReceived };
|
||||
}
|
||||
|
||||
describe("DaemonClient response body deadline", () => {
|
||||
it.each(["json", "text", "write"])("bounds %s body reads after headers arrive", async (kind) => {
|
||||
const endpoint = await slowBodyServer(4_000);
|
||||
const observed = observedFetch();
|
||||
const client = new DaemonClient(endpoint.url, { timeoutMs: 1_000, fetchImpl: observed.fetchImpl });
|
||||
const request = kind === "json" ? client.get("/read") : kind === "text" ? client.getText("/read") : client.post("/write", { action: "once" });
|
||||
const result = request.then(() => null, (error: unknown) => error);
|
||||
await expect(Promise.race([endpoint.headersWritten.then(() => "headers written"), result])).resolves.toBe("headers written");
|
||||
await expect(Promise.race([observed.headersReceived.then(() => "headers received"), result])).resolves.toBe("headers received");
|
||||
expect(await result).toBeInstanceOf(DaemonTimeoutError);
|
||||
expect(endpoint.requests()).toBe(1); // no automatic retry of an accepted write
|
||||
await expect.poll(endpoint.closedBeforeEnd).toBe(1); // cancel the body/socket, not just the caller promise
|
||||
});
|
||||
|
||||
it.each([false, true])("reports an unknown POST outcome after the response body drops (json=%s)", async (json) => {
|
||||
const endpoint = await slowBodyServer(4_000);
|
||||
const observed = observedFetch();
|
||||
const client = new DaemonClient(endpoint.url, { timeoutMs: 2_000, fetchImpl: observed.fetchImpl });
|
||||
const program = new Command().option("--json");
|
||||
program.command("write").action(async () => { await client.post("/write", { action: "once" }); });
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const exitCodes: number[] = [];
|
||||
const run = runProgram(program, ["node", "rig", "write", ...(json ? ["--json"] : [])], {
|
||||
out: (line) => out.push(line), err: (line) => err.push(line), exit: (code) => exitCodes.push(code),
|
||||
});
|
||||
await expect(Promise.race([endpoint.headersWritten.then(() => "headers written"), run])).resolves.toBe("headers written");
|
||||
await expect(Promise.race([observed.headersReceived.then(() => "headers received"), run])).resolves.toBe("headers received");
|
||||
endpoint.dropBody();
|
||||
expect(await run).toBe(1);
|
||||
expect(exitCodes).toEqual([1]);
|
||||
const guidance = (json ? out : err).join("\n");
|
||||
expect(guidance).toMatch(/unreadable response/i);
|
||||
expect(guidance).toMatch(/outcome.*UNKNOWN/i);
|
||||
expect(guidance).toMatch(/re-check current state/i);
|
||||
expect(guidance).not.toMatch(/not delivered|cannot connect|rig up|rig daemon start/i);
|
||||
if (json) expect(JSON.parse(guidance).error.consequence).toMatch(/UNKNOWN/);
|
||||
expect(endpoint.requests()).toBe(1);
|
||||
await expect.poll(endpoint.closedBeforeEnd).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the per-request deadline through the complete body", async () => {
|
||||
const endpoint = await slowBodyServer(100);
|
||||
const client = new DaemonClient(endpoint.url, { timeoutMs: 20 });
|
||||
await expect(client.get("/read", { timeoutMs: 1_000 })).resolves.toEqual({ status: 200, data: { ok: true } });
|
||||
expect(endpoint.requests()).toBe(1);
|
||||
expect(endpoint.closedBeforeEnd()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn as spawnChild } from "node:child_process";
|
||||
import {
|
||||
runCrossHostCommand,
|
||||
classifyResult,
|
||||
@@ -341,3 +342,52 @@ describe("D13 — remote rig resolution + loud 127", () => {
|
||||
expect(d13Classify(255, "", "Permission denied (publickey)").failedStep).toBe("permission-gate");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("remote output byte boundaries", () => {
|
||||
it("preserves UTF-8 from real child stdout and stderr pipes", async () => {
|
||||
const expected = "界🙂café";
|
||||
const spawn: SpawnFn = () => spawnChild(process.execPath, ["-e", `
|
||||
const bytes = Buffer.from(${JSON.stringify(expected)});
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
process.stdout.write(bytes.subarray(i, i + 1));
|
||||
process.stderr.write(bytes.subarray(i, i + 1));
|
||||
if (++i === bytes.length) clearInterval(timer);
|
||||
}, 20);
|
||||
`]);
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "capture", "dev@rig"], { spawn });
|
||||
expect(result).toMatchObject({ ok: true, stdout: expected, stderr: expected });
|
||||
});
|
||||
|
||||
it("flushes an incomplete final UTF-8 character from both pipes on close", async () => {
|
||||
const spawn: SpawnFn = () => {
|
||||
const child = makeMockChild();
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from([0xe7]));
|
||||
child.stderr.emit("data", Buffer.from([0xf0, 0x9f]));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child as never;
|
||||
};
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "capture", "dev@rig"], { spawn });
|
||||
expect(result).toMatchObject({ ok: true, stdout: "\ufffd", stderr: "\ufffd" });
|
||||
});
|
||||
|
||||
it.each([0, 1])("preserves split UTF-8 on both pipes for exit %s", async (exitCode) => {
|
||||
const stdout = '{"content":"界🙂café"}\n';
|
||||
const stderr = "diagnostic: naïve 日本語\n";
|
||||
const spawn: SpawnFn = () => {
|
||||
const child = makeMockChild();
|
||||
queueMicrotask(() => {
|
||||
for (const byte of Buffer.from(stdout)) child.stdout.emit("data", Buffer.from([byte]));
|
||||
for (const byte of Buffer.from(stderr)) child.stderr.emit("data", Buffer.from([byte]));
|
||||
child.emit("close", exitCode);
|
||||
});
|
||||
return child as never;
|
||||
};
|
||||
const result = await runCrossHostCommand(HOST, ["rig", "capture", "dev@rig", "--json"], { spawn });
|
||||
expect(result).toMatchObject({ stdout, stderr });
|
||||
expect(result.failedStep).toBe(exitCode === 0 ? "none" : "remote-command-failed");
|
||||
});
|
||||
});
|
||||
@@ -1012,6 +1012,41 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
expect(isAlive(123)).toBe(true);
|
||||
});
|
||||
|
||||
it("readProcessState reads the ps state column on POSIX", async () => {
|
||||
const { readProcessState } = await import("../src/commands/daemon.js");
|
||||
const calls: Array<{ file: string; args: string[] }> = [];
|
||||
const state = readProcessState(123, "linux", (file, args) => {
|
||||
calls.push({ file, args });
|
||||
return "S\n";
|
||||
});
|
||||
|
||||
expect(state).toBe("S\n");
|
||||
expect(calls).toEqual([{ file: "ps", args: ["-o", "state=", "-p", "123"] }]);
|
||||
});
|
||||
|
||||
it("readProcessState returns null on POSIX when ps fails", async () => {
|
||||
const { readProcessState } = await import("../src/commands/daemon.js");
|
||||
const state = readProcessState(123, "darwin", () => {
|
||||
throw new Error("no such process");
|
||||
});
|
||||
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
|
||||
it("readProcessState does not shell out to ps on Windows", async () => {
|
||||
const { readProcessState, createIsProcessAlive } = await import("../src/commands/daemon.js");
|
||||
const run = () => {
|
||||
throw new Error("ps must not run on win32");
|
||||
};
|
||||
|
||||
expect(readProcessState(123, "win32", run)).toBe("R");
|
||||
const isAlive = createIsProcessAlive({
|
||||
signalCheck: () => true,
|
||||
readProcessState: (pid) => readProcessState(pid, "win32", run),
|
||||
});
|
||||
expect(isAlive(123)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDaemonPath", () => {
|
||||
|
||||
@@ -44,6 +44,8 @@ function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCod
|
||||
describe("rig env", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let probeStatus = "fresh";
|
||||
let probeError: string | undefined;
|
||||
|
||||
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
|
||||
|
||||
@@ -76,6 +78,8 @@ describe("rig env", () => {
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
hasServices: true,
|
||||
probeStatus,
|
||||
...(probeError ? { probeError } : {}),
|
||||
kind: "compose",
|
||||
projectName: "my-rig",
|
||||
receipt: {
|
||||
@@ -151,6 +155,35 @@ describe("rig env", () => {
|
||||
expect(parsed.receipt.services).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["stale", "no_orchestrator"])("env status discloses a saved receipt when probe status is %s", async (status) => {
|
||||
probeStatus = status;
|
||||
probeError = status === "stale" ? "Docker daemon connection refused" : undefined;
|
||||
try {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("saved receipt");
|
||||
expect(output).toContain(status);
|
||||
if (probeError) expect(output).toContain(probeError);
|
||||
expect(output).toContain("vault");
|
||||
expect(output).toContain("healthy");
|
||||
expect(exitCode).toBeUndefined();
|
||||
} finally { probeStatus = "fresh"; probeError = undefined; }
|
||||
});
|
||||
|
||||
it("env status --json preserves stale probe metadata without extra output", async () => {
|
||||
probeStatus = "stale";
|
||||
probeError = "Docker daemon connection refused";
|
||||
try {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig", "--json"]);
|
||||
});
|
||||
expect(JSON.parse(logs.join(""))).toMatchObject({ probeStatus, probeError, receipt: { services: [{ health: "healthy" }] } });
|
||||
expect(exitCode).toBeUndefined();
|
||||
} finally { probeStatus = "fresh"; probeError = undefined; }
|
||||
});
|
||||
|
||||
it("env logs returns service logs", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "logs", "my-rig"]);
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "../src/fetch-with-timeout.js";
|
||||
|
||||
describe("fetchWithTimeout", () => {
|
||||
it("shares one deadline across waiting for headers and reading the body", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let signal: AbortSignal | undefined;
|
||||
let headersReceived = false;
|
||||
const fetchImpl: typeof fetch = async (_url, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
headersReceived = true;
|
||||
return new Response("body");
|
||||
};
|
||||
const result = fetchWithTimeout(fetchImpl, "http://localhost:7433/read", {}, {
|
||||
timeoutMs: 1_000,
|
||||
timeoutMessage: "complete request timed out",
|
||||
consumeResponse: async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
setTimeout(resolve, 600);
|
||||
signal!.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
|
||||
});
|
||||
},
|
||||
}).then(() => null, (error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(headersReceived).toBe(true);
|
||||
expect(signal!.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(signal!.aborted).toBe(true);
|
||||
expect(await result).toBeInstanceOf(FetchTimeoutError);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects with FetchTimeoutError when fetch blackholes", async () => {
|
||||
const fetchImpl: typeof fetch = ((_url: string | URL | Request, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
parseFilePathArg,
|
||||
checkLocalPath,
|
||||
@@ -312,3 +315,65 @@ describe("classifyRsyncResult / runFileCopy", () => {
|
||||
expect(res.hint).toContain("brew install rsync");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("file copy output byte boundaries", () => {
|
||||
it("preserves split UTF-8 on both rsync output pipes", async () => {
|
||||
const text = "copied café/日本語.md 🙂\n";
|
||||
const spawn = (() => {
|
||||
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
||||
child.stdout = new EventEmitter(); child.stderr = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
for (const byte of Buffer.from(text)) {
|
||||
child.stdout.emit("data", Buffer.from([byte]));
|
||||
child.stderr.emit("data", Buffer.from([byte]));
|
||||
}
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
}) as unknown as NonNullable<Parameters<typeof runFileCopy>[1]>["spawn"];
|
||||
const result = await runFileCopy({ src: local("/tmp/src"), dst: local("/tmp/dst"), dryRun: true }, { spawn });
|
||||
expect(result).toMatchObject({ ok: true, stdout: text, stderr: text });
|
||||
});
|
||||
});
|
||||
|
||||
let rsyncAvailable = false;
|
||||
try { execFileSync("rsync", ["--version"], { stdio: "ignore" }); rsyncAvailable = true; }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
describe("local directory copy semantics", () => {
|
||||
it("preserves the trailing slash in the normalized local source and rsync argv", () => {
|
||||
const source = "." + path.sep + "copy-source" + path.sep;
|
||||
const planned = planFileCopy(source, "." + path.sep + "copy-destination");
|
||||
expect(planned.ok).toBe(true);
|
||||
if (!planned.ok) return;
|
||||
const expectedSource = path.resolve("copy-source") + path.sep;
|
||||
expect(planned.plan.src.path).toBe(expectedSource);
|
||||
expect(buildRsyncArgv(planned.plan).slice(-2)[0]).toBe(expectedSource);
|
||||
});
|
||||
|
||||
it.skipIf(!rsyncAvailable).each([false, true])("native rsync preserves source directory trailing slash=%s (requires installed rsync)", async (contentsOnly) => {
|
||||
const root = mkdtempSync(path.join(process.cwd(), "openrig-copy-contents-"));
|
||||
try {
|
||||
const src = path.join(root, "source");
|
||||
const dst = path.join(root, "destination");
|
||||
mkdirSync(src); mkdirSync(dst);
|
||||
writeFileSync(path.join(src, "artifact.txt"), "owned test artifact");
|
||||
const localSrc = "." + path.sep + path.relative(process.cwd(), src);
|
||||
const localDst = "." + path.sep + path.relative(process.cwd(), dst);
|
||||
const planned = planFileCopy(localSrc + (contentsOnly ? path.sep : ""), localDst);
|
||||
expect(planned.ok).toBe(true);
|
||||
if (!planned.ok) return;
|
||||
const result = await runFileCopy(planned.plan);
|
||||
expect(result.ok, result.stderr).toBe(true);
|
||||
const expected = path.join(dst, ...(contentsOnly ? [] : ["source"]), "artifact.txt");
|
||||
expect(existsSync(expected), `rsync destination should be ${expected}`).toBe(true);
|
||||
expect(readFileSync(expected, "utf8")).toBe("owned test artifact");
|
||||
if (contentsOnly) expect(existsSync(path.join(dst, "source"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// OPR.0.3.3.13.1 - CLI surface-detection parser POC tests (AC-1..AC-5).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { beforeAll, describe, it, expect } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
@@ -30,6 +31,25 @@ function readSource(rel: string): { name: string; text: string } {
|
||||
const FROM = "v0.3.1";
|
||||
const TO = "v0.3.2";
|
||||
|
||||
function hasRef(ref: string): boolean {
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: here, stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The worked example and determinism checks diff two real release tags. Forks and shallow or
|
||||
// --no-tags clones lack them, and a throw while collecting a describe fails the whole file,
|
||||
// unit tests included. Skip those two blocks locally when the tags are absent; CI clones with
|
||||
// full history, so there a missing tag still fails loudly.
|
||||
const hasReleaseTags = hasRef(FROM) && hasRef(TO);
|
||||
const skipTagTests = !hasReleaseTags && process.env.CI !== "true";
|
||||
if (skipTagTests) {
|
||||
console.warn(`release-surface: tags ${FROM}/${TO} not found; skipping tag-diff tests (git fetch --tags to run them)`);
|
||||
}
|
||||
|
||||
describe("release-surface parser - extract (unit)", () => {
|
||||
it("AC-2 crux: emits the registration name `mode`, never the filename `rig-mode` (verb renamed from `policy`, B7)", () => {
|
||||
const surface = extractSurfaceFromSources([readSource("src/commands/rig-mode.ts")]);
|
||||
@@ -95,8 +115,11 @@ describe("release-surface parser - diff (AC-1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("release-surface parser - v0.3.1..v0.3.2 worked example (AC-2)", () => {
|
||||
const diff = generateSurfaceDiff({ from: FROM, to: TO, cwd: here });
|
||||
describe.skipIf(skipTagTests)("release-surface parser - v0.3.1..v0.3.2 worked example (AC-2)", () => {
|
||||
let diff: SurfaceDiff;
|
||||
beforeAll(() => {
|
||||
diff = generateSurfaceDiff({ from: FROM, to: TO, cwd: here });
|
||||
});
|
||||
|
||||
it("emits exactly `policy` and `scope` as new top-level commands (no false positives, no filename)", () => {
|
||||
const names = diff.added_commands.map((c) => c.name).sort();
|
||||
@@ -147,7 +170,7 @@ describe("release-surface parser - v0.3.1..v0.3.2 worked example (AC-2)", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("release-surface parser - determinism (AC-4)", () => {
|
||||
describe.skipIf(skipTagTests)("release-surface parser - determinism (AC-4)", () => {
|
||||
it("produces byte-identical YAML across two runs on the same refs", () => {
|
||||
const a = diffToYaml(generateSurfaceDiff({ from: FROM, to: TO, cwd: here }));
|
||||
const b = diffToYaml(generateSurfaceDiff({ from: FROM, to: TO, cwd: here }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { seatCommand, handoverCommand } from "../src/commands/seat.js";
|
||||
import { STATE_FILE, type DaemonState, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
@@ -208,6 +209,7 @@ const FRESH_LAUNCH_RESULT = {
|
||||
|
||||
describe("rig seat status", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -339,6 +341,63 @@ describe("rig seat status", () => {
|
||||
expect(output).toContain("No continuity source was used; siblings and durable work were preserved.");
|
||||
});
|
||||
|
||||
it("launch waits for a ready response beyond the default five-second timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchImpl = vi.fn((_url: unknown, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve(Response.json(FRESH_LAUNCH_RESULT)), 6_000);
|
||||
init!.signal!.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(init!.signal!.reason);
|
||||
}, { once: true });
|
||||
}));
|
||||
const deps = makeDeps({ status: 200, data: FRESH_LAUNCH_RESULT }, []);
|
||||
deps.clientFactory = url => new DaemonClient(url, { fetchImpl });
|
||||
const result = captureLogs(() => makeCommand(deps).parseAsync([
|
||||
"node", "rig", "seat", "launch", "dev-impl@seat-rig",
|
||||
"--fresh", "--stop", "--reason", "deliberate blank restart", "--json",
|
||||
]).then(() => undefined)).catch(error => ({ error }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
|
||||
const output = await result;
|
||||
expect(output).toMatchObject({ errors: [], exitCode: undefined });
|
||||
if (!("logs" in output)) throw output.error;
|
||||
expect(JSON.parse(output.logs.join("\n"))).toMatchObject({ status: "ready", generation: "gen-fresh" });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("launch reports an unknown outcome without retrying on timeout (json=%s)", async json => {
|
||||
vi.useFakeTimers();
|
||||
const fetchImpl = vi.fn((_url: unknown, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true });
|
||||
}));
|
||||
const deps = makeDeps({ status: 200, data: FRESH_LAUNCH_RESULT }, []);
|
||||
deps.clientFactory = url => new DaemonClient(url, { fetchImpl });
|
||||
const result = captureLogs(() => makeCommand(deps).parseAsync([
|
||||
"node", "rig", "seat", "launch", "dev-impl@seat-rig",
|
||||
"--fresh", "--stop", "--reason", "deliberate blank restart", ...(json ? ["--json"] : []),
|
||||
]).then(() => undefined)).catch(error => ({ error }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
const output = await result;
|
||||
expect(output).toMatchObject({ exitCode: 1 });
|
||||
if (!("logs" in output)) throw output.error;
|
||||
if (json) {
|
||||
expect(output.errors).toEqual([]);
|
||||
expect(JSON.parse(output.logs.join("\n"))).toMatchObject({
|
||||
status: "unknown",
|
||||
code: "launch_outcome_unknown",
|
||||
guidance: expect.stringContaining("rig seat status dev-impl@seat-rig"),
|
||||
});
|
||||
} else {
|
||||
expect(output.logs).toEqual([]);
|
||||
expect(output.errors.join("\n")).toContain("may still be in progress");
|
||||
expect(output.errors.join("\n")).toContain("rig seat status dev-impl@seat-rig");
|
||||
}
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handover --dry-run --json prints the stable planner shape", async () => {
|
||||
const paths: string[] = [];
|
||||
const deps = makeDeps({ status: 200, data: HANDOVER_PLAN }, paths);
|
||||
|
||||
@@ -950,3 +950,115 @@ describe("rig setup permission-policy menu copy (frozen)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #194 — a Codex provider that does not use an OpenAI login.
|
||||
describe("codex_auth — provider-aware Codex readiness (#194)", () => {
|
||||
const CODEX_HOME = "/synthetic/codex-cli-home";
|
||||
const BEDROCK_CONFIG = [
|
||||
'model_provider = "bedrock-runtime-us"',
|
||||
"",
|
||||
"[model_providers.bedrock-runtime-us]",
|
||||
'name = "Amazon Bedrock Runtime"',
|
||||
'env_key = "AWS_BEARER_TOKEN_BEDROCK"',
|
||||
'wire_api = "responses"',
|
||||
"requires_openai_auth = false",
|
||||
"",
|
||||
].join("\n");
|
||||
const TOKEN = "synthetic-bedrock-token-value";
|
||||
|
||||
function providerDeps(opts: {
|
||||
config?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
loggedIn?: boolean;
|
||||
codexInstalled?: boolean;
|
||||
}) {
|
||||
const commands: string[] = [];
|
||||
const reads: string[] = [];
|
||||
const base = makeDeps();
|
||||
const deps = makeDeps({
|
||||
env: opts.env ?? { HOME: "/synthetic/home", CODEX_HOME },
|
||||
readFile: (p: string) => {
|
||||
reads.push(p);
|
||||
return p === `${CODEX_HOME}/config.toml` ? (opts.config ?? null) : null;
|
||||
},
|
||||
exec: (cmd: string) => {
|
||||
commands.push(cmd);
|
||||
if (cmd === "codex --version" && opts.codexInstalled === false) throw new Error("codex: command not found");
|
||||
if (cmd === "npm install -g @openai/codex" && opts.codexInstalled === false) throw new Error("install refused in test");
|
||||
if (cmd === "codex login status" && !opts.loggedIn) throw new Error("Not logged in");
|
||||
return base.exec(cmd);
|
||||
},
|
||||
});
|
||||
return { deps, commands, reads };
|
||||
}
|
||||
|
||||
const codexAuthStep = (result: SetupResult) => result.steps.find((s) => s.id === "codex_auth");
|
||||
|
||||
it("passes the explicit Bedrock provider from the selected CODEX_HOME without an OpenAI login", async () => {
|
||||
const { deps, commands, reads } = providerDeps({
|
||||
config: BEDROCK_CONFIG,
|
||||
env: { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN },
|
||||
loggedIn: false,
|
||||
});
|
||||
const result = await runSetup(deps, {});
|
||||
const step = codexAuthStep(result);
|
||||
expect(step?.status).toBe("pass");
|
||||
expect(step?.message).toContain("AWS_BEARER_TOKEN_BEDROCK");
|
||||
expect(step?.message).toContain("not that the provider accepts it");
|
||||
expect(JSON.stringify(result)).not.toContain(TOKEN);
|
||||
expect(commands).not.toContain("codex login status");
|
||||
expect(reads).toContain(`${CODEX_HOME}/config.toml`);
|
||||
expect(reads).not.toContain("/synthetic/home/.codex/config.toml");
|
||||
});
|
||||
|
||||
it("fails with the provider's variable named, never its value, when the token is missing or blank", async () => {
|
||||
for (const env of [
|
||||
{ HOME: "/synthetic/home", CODEX_HOME },
|
||||
{ HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: " " },
|
||||
]) {
|
||||
const { deps, commands } = providerDeps({ config: BEDROCK_CONFIG, env, loggedIn: true });
|
||||
const result = await runSetup(deps, {});
|
||||
const step = codexAuthStep(result);
|
||||
expect(step?.status).toBe("fail");
|
||||
expect(step?.message).toContain("AWS_BEARER_TOKEN_BEDROCK");
|
||||
expect(step?.fixHint).toContain("Export AWS_BEARER_TOKEN_BEDROCK");
|
||||
expect(step?.fixHint).not.toContain("codex login");
|
||||
expect(result.ready).toBe(false);
|
||||
expect(commands).not.toContain("codex login status");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the OpenAI login check for the default provider and for providers that require it", async () => {
|
||||
const requiresLogin = BEDROCK_CONFIG.replace("requires_openai_auth = false", "requires_openai_auth = true");
|
||||
for (const config of [null, 'model_provider = "openai"\n', requiresLogin]) {
|
||||
const env = { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN };
|
||||
const ok = providerDeps({ config, env, loggedIn: true });
|
||||
expect(codexAuthStep(await runSetup(ok.deps, {}))?.status).toBe("pass");
|
||||
expect(ok.commands).toContain("codex login status");
|
||||
|
||||
const notLoggedIn = providerDeps({ config, env, loggedIn: false });
|
||||
const step = codexAuthStep(await runSetup(notLoggedIn.deps, {}));
|
||||
expect(step?.status).toBe("fail");
|
||||
expect(step?.fixHint).toContain("codex login");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a malformed-config explanation when the fallback OpenAI login fails", async () => {
|
||||
const env = { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN };
|
||||
const { deps } = providerDeps({ config: "model_provider = [broken", env, loggedIn: false });
|
||||
const step = codexAuthStep(await runSetup(deps, {}));
|
||||
expect(step?.status).toBe("fail");
|
||||
expect(step?.message).toContain("config.toml could not be parsed");
|
||||
});
|
||||
|
||||
it("skips the provider check when the Codex executable is missing", async () => {
|
||||
const { deps } = providerDeps({
|
||||
config: BEDROCK_CONFIG,
|
||||
env: { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN },
|
||||
codexInstalled: false,
|
||||
});
|
||||
const result = await runSetup(deps, {});
|
||||
expect(result.steps.find((s) => s.id === "codex_install")?.status).toBe("fail");
|
||||
expect(codexAuthStep(result)?.status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// #141: `rig up` terminal output for the stopped-generation archive. The archive notice must reach the user
|
||||
// on the attention 409 too (the replacement is kept there), and a generation_unconfirmed refusal prints its
|
||||
// own actionable message. Actual command renderer and daemon attention builder; the client is stubbed.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { upCommand } from "../src/commands/up.js";
|
||||
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import type { DaemonClient } from "../src/client.js";
|
||||
import { buildAttentionResponse } from "../../daemon/src/routes/up.js";
|
||||
|
||||
const OLD = "01OLDGENERATION00000000000";
|
||||
const NOTICE = `Archived the stopped earlier "first-project" rig ${OLD}; its records are kept. Restore it with: rig unarchive ${OLD}`;
|
||||
|
||||
async function render(data: Record<string, unknown>, status: number, json = false) {
|
||||
const oldExit = process.exitCode;
|
||||
const out: string[] = [], err: string[] = [];
|
||||
const log = vi.spyOn(console, "log").mockImplementation((...s) => { out.push(s.join(" ")); });
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...s) => { err.push(s.join(" ")); });
|
||||
const forbidden = () => { throw new Error("unexpected lifecycle operation"); };
|
||||
const lifecycleDeps = {
|
||||
spawn: forbidden, kill: forbidden, writeFile: forbidden, removeFile: forbidden,
|
||||
mkdirp: forbidden, openForAppend: forbidden,
|
||||
fetch: async () => ({ ok: true }), isProcessAlive: () => true,
|
||||
exists: (p: string) => p === STATE_FILE,
|
||||
readFile: (p: string) => p === STATE_FILE ? JSON.stringify({ pid: 123, port: 12345, db: "mock", startedAt: "2000-01-01" }) : null,
|
||||
} as LifecycleDeps;
|
||||
const client = { get: vi.fn(async () => ({ status: 200, data: [] })), post: vi.fn(async () => ({ status, data })) };
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
const cmd = new Command().exitOverride().addCommand(upCommand({
|
||||
lifecycleDeps, clientFactory: () => client as unknown as DaemonClient, preflightExec: forbidden,
|
||||
}));
|
||||
await cmd.parseAsync(["node", "rig", "up", "first-project", ...(json ? ["--json"] : [])]);
|
||||
return { out: out.join("\n"), err: err.join("\n"), exit: process.exitCode };
|
||||
} finally { process.exitCode = oldExit; log.mockRestore(); error.mockRestore(); }
|
||||
}
|
||||
|
||||
function attentionWithNotice() {
|
||||
const attentionNodes = [{ logicalId: "dev.owner", sessionName: "dev-owner@first-project", reason: "trust_gate", evidence: "trust" }];
|
||||
const result = { rigId: "NEWRIG", warnings: [NOTICE], stages: [{ stage: "import_rig", status: "blocked",
|
||||
detail: { code: "attention_required", message: "1 member needs attention.", attentionNodes } }] };
|
||||
return { ...result, ...buildAttentionResponse(result)! };
|
||||
}
|
||||
|
||||
describe("#141 rig up output for the stopped-generation archive", () => {
|
||||
it("attention 409: prints the archive notice with the old rig id and its unarchive instruction", async () => {
|
||||
const r = await render(attentionWithNotice(), 409);
|
||||
expect(r.err).toContain(`rig unarchive ${OLD}`);
|
||||
expect(r.err).toContain("dev.owner");
|
||||
expect(r.exit).toBe(1);
|
||||
});
|
||||
|
||||
it("attention 409 --json: the JSON body is unchanged and nothing else is printed", async () => {
|
||||
const data = attentionWithNotice();
|
||||
const r = await render(data, 409, true);
|
||||
expect(r.out).toBe(JSON.stringify(data));
|
||||
expect(r.err).toBe("");
|
||||
expect(r.exit).toBe(1);
|
||||
});
|
||||
|
||||
it("generation_unconfirmed 409: prints its actionable message, not the validate-your-spec fallback", async () => {
|
||||
const message = `A rig named "first-project" already exists (${OLD}), and OpenRig could not confirm it is stopped: tmux session x still exists. Nothing was created. Restore it with 'rig up first-project', or archive it first with 'rig archive ${OLD}'.`;
|
||||
const r = await render({ code: "generation_unconfirmed", error: message, errors: [message],
|
||||
stages: [{ stage: "import_rig", status: "failed", detail: { code: "generation_unconfirmed" } }] }, 409);
|
||||
expect(r.err).toContain(message);
|
||||
expect(r.err).not.toMatch(/validate your spec|Check daemon logs/);
|
||||
expect(r.exit).toBe(1);
|
||||
});
|
||||
|
||||
it("control: on success a warning is printed exactly once", async () => {
|
||||
const r = await render({ status: "completed", rigId: "NEWRIG", stages: [], warnings: [NOTICE] }, 201);
|
||||
expect(r.err.split(`rig unarchive ${OLD}`).length - 1).toBe(1);
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -3,8 +3,9 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const skillRoot = path.resolve(new URL("..", import.meta.url).pathname);
|
||||
const skillRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const restoreScript = path.join(skillRoot, "scripts", "restore-from-jsonl.mjs");
|
||||
// P6(C) injectable output-root: the packet base defaults to the shared
|
||||
// /tmp/claude-compaction-restore (production), but becomes per-run isolated when the
|
||||
|
||||
@@ -15,7 +15,7 @@ profiles:
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
plugins: [shared:openrig-core]
|
||||
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
|
||||
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config, shared:claude-activity-hooks]
|
||||
|
||||
resources:
|
||||
guidance:
|
||||
|
||||
@@ -15,7 +15,7 @@ profiles:
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
plugins: [shared:openrig-core]
|
||||
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
|
||||
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config, shared:claude-activity-hooks]
|
||||
|
||||
resources:
|
||||
guidance:
|
||||
|
||||
@@ -15,7 +15,7 @@ profiles:
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
plugins: [shared:openrig-core]
|
||||
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
|
||||
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config, shared:claude-activity-hooks]
|
||||
|
||||
resources:
|
||||
guidance:
|
||||
|
||||
@@ -464,7 +464,16 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
if (!targetDir) return true;
|
||||
|
||||
this.fs.mkdirp(targetDir);
|
||||
const isDir = this.fs.listFiles ? this.fs.listFiles(entry.absolutePath).length > 0 : false;
|
||||
let isDir = false;
|
||||
if (this.fs.listFiles) {
|
||||
try {
|
||||
isDir = this.fs.listFiles(entry.absolutePath).length > 0;
|
||||
} catch {
|
||||
// File-shaped sources make the production listFiles (recursive
|
||||
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
|
||||
isDir = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDir && this.fs.listFiles) {
|
||||
// Directory-shaped: recursive copy
|
||||
|
||||
@@ -563,7 +563,16 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (!targetDir) return true;
|
||||
|
||||
this.fs.mkdirp(targetDir);
|
||||
const isDir = this.fs.listFiles ? this.fs.listFiles(entry.absolutePath).length > 0 : false;
|
||||
let isDir = false;
|
||||
if (this.fs.listFiles) {
|
||||
try {
|
||||
isDir = this.fs.listFiles(entry.absolutePath).length > 0;
|
||||
} catch {
|
||||
// File-shaped sources make the production listFiles (recursive
|
||||
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
|
||||
isDir = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDir && this.fs.listFiles) {
|
||||
for (const file of this.fs.listFiles(entry.absolutePath)) {
|
||||
@@ -1194,7 +1203,7 @@ function stripCodexActivityHooks(content: string): string {
|
||||
"m"
|
||||
);
|
||||
if (!pattern.test(content)) return content;
|
||||
return content.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
|
||||
return content.replace(pattern, "\n");
|
||||
}
|
||||
|
||||
/** Ensure `[features].hooks = true` (canonical key; not the deprecated codex_hooks alias). */
|
||||
|
||||
@@ -388,7 +388,16 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
|
||||
const targetDir = nodePath.join(agentDir, "skills", entry.effectiveId);
|
||||
this.fs.mkdirp(targetDir);
|
||||
const isDir = this.fs.listFiles ? this.fs.listFiles(entry.absolutePath).length > 0 : false;
|
||||
let isDir = false;
|
||||
if (this.fs.listFiles) {
|
||||
try {
|
||||
isDir = this.fs.listFiles(entry.absolutePath).length > 0;
|
||||
} catch {
|
||||
// File-shaped sources make the production listFiles (recursive
|
||||
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
|
||||
isDir = false;
|
||||
}
|
||||
}
|
||||
if (isDir && this.fs.listFiles) {
|
||||
for (const file of this.fs.listFiles(entry.absolutePath)) {
|
||||
const dest = nodePath.join(targetDir, file);
|
||||
|
||||
@@ -293,7 +293,16 @@ export class StubRuntimeAdapter implements RuntimeAdapter {
|
||||
if (entry.category === "skill") {
|
||||
const targetDir = nodePath.join(binding.cwd, ".openrig", "stub", "skills", entry.effectiveId);
|
||||
this.fsOps.mkdirp(targetDir);
|
||||
const isDir = this.fsOps.listFiles ? this.fsOps.listFiles(entry.absolutePath).length > 0 : false;
|
||||
let isDir = false;
|
||||
if (this.fsOps.listFiles) {
|
||||
try {
|
||||
isDir = this.fsOps.listFiles(entry.absolutePath).length > 0;
|
||||
} catch {
|
||||
// File-shaped sources make the production listFiles (recursive
|
||||
// fs.readdirSync walk) throw ENOTDIR — treat the entry as file-shaped.
|
||||
isDir = false;
|
||||
}
|
||||
}
|
||||
if (isDir && this.fsOps.listFiles) {
|
||||
for (const file of this.fsOps.listFiles(entry.absolutePath)) {
|
||||
const dest = nodePath.join(targetDir, file);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { ExecFn } from "./tmux.js";
|
||||
import type { ArgvExecFn, ExecFn } from "./tmux.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function extractExecOutput(err: unknown): string {
|
||||
if (!err || typeof err !== "object") return "";
|
||||
@@ -31,3 +32,24 @@ export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Production ArgvExecFn for TmuxAdapter (the Windows/psmux path).
|
||||
* Runs argv[0] directly via execFile: no shell, no quoting layer. argv[0]
|
||||
* stays the literal "tmux" token — psmux ships a `tmux` alias on PATH, so
|
||||
* binary resolution is identical to the tmux case.
|
||||
*/
|
||||
export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<string> => {
|
||||
const [bin, ...args] = argv;
|
||||
if (!bin) throw new Error("execArgvCommand: empty argv");
|
||||
try {
|
||||
const { stdout } = await execFileAsync(bin, args, { windowsHide: true });
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
const output = extractExecOutput(err);
|
||||
if (err instanceof Error && output && !err.message.includes(output)) {
|
||||
throw new Error(`${err.message}\n${output}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,15 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
export type ExecFn = (cmd: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Argv exec: run the multiplexer WITHOUT a shell (execFile-style — the
|
||||
* binary is argv[0], the rest are verbatim arguments, no quoting layer).
|
||||
* This is the Windows/psmux path: POSIX shells, single-quote quoting and
|
||||
* `/dev/null` redirections do not exist there. Opt-in via the constructor;
|
||||
* when absent every method uses the legacy shell-string path unchanged.
|
||||
*/
|
||||
export type ArgvExecFn = (argv: string[]) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Injectable file/buffer operations for `sendText`.
|
||||
* Split out so tests can observe temp-file writes and unique-name generation
|
||||
@@ -104,9 +113,10 @@ const WINDOW_FORMAT = [
|
||||
"#{window_panes}",
|
||||
"#{window_active}",
|
||||
].join(TMUX_FIELD_SEPARATOR);
|
||||
// tmux 3.6 sanitizes literal control characters in -F output to underscores,
|
||||
// so tab-delimited session and pane rows become unparseable. Use a printable
|
||||
// delimiter for these adapter-owned formats instead.
|
||||
// tmux 3.6 sanitizes literal control characters in -F output to underscores, and
|
||||
// tmux 3.3a replaces tabs with underscores, so tab-delimited rows become
|
||||
// unparseable on those versions. EVERY adapter-owned format uses the printable
|
||||
// delimiter instead.
|
||||
const PANE_FORMAT = [
|
||||
"#{pane_id}",
|
||||
"#{pane_index}",
|
||||
@@ -115,7 +125,16 @@ const PANE_FORMAT = [
|
||||
"#{pane_height}",
|
||||
"#{pane_active}",
|
||||
].join(TMUX_FIELD_SEPARATOR);
|
||||
const CLIENT_FORMAT = "#{client_name}\t#{client_session}";
|
||||
const CLIENT_FORMAT = [
|
||||
"#{client_name}",
|
||||
"#{client_session}",
|
||||
].join(TMUX_FIELD_SEPARATOR);
|
||||
const CURSOR_FORMAT = [
|
||||
"#{cursor_x}",
|
||||
"#{cursor_y}",
|
||||
"#{pane_width}",
|
||||
"#{pane_height}",
|
||||
].join(TMUX_FIELD_SEPARATOR);
|
||||
|
||||
function isNoServerError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes("no server running");
|
||||
@@ -182,6 +201,18 @@ function shellQuote(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
/** Elements safe to leave bare in a POSIX shell word list. */
|
||||
const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
||||
|
||||
/** Join argv for a POSIX shell — the legacy string form. Only used when
|
||||
* no ArgvExecFn is wired. Flags and safe words stay bare (matching the
|
||||
* historical template strings); anything else is single-quoted. The argv
|
||||
* arrays stay the single source of truth for what is EXECUTED on either
|
||||
* path — this join only renders them for a shell. */
|
||||
function posixJoinArgv(argv: string[]): string {
|
||||
return argv.map((a) => (SHELL_SAFE.test(a) ? a : shellQuote(a))).join(" ");
|
||||
}
|
||||
|
||||
function parseSessionLine(line: string): TmuxSession | null {
|
||||
const parts = line.split(TMUX_FIELD_SEPARATOR);
|
||||
if (parts.length < 4) return null;
|
||||
@@ -196,13 +227,14 @@ function parseSessionLine(line: string): TmuxSession | null {
|
||||
}
|
||||
|
||||
function parseClientLine(line: string): TmuxClient | null {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 2) return null;
|
||||
const name = parts[0]!;
|
||||
if (name === "") return null;
|
||||
// First-separator split keeps any separator inside the session name intact.
|
||||
const separator = line.indexOf(TMUX_FIELD_SEPARATOR);
|
||||
if (separator <= 0) return null;
|
||||
const session = line.slice(separator + 1);
|
||||
if (session === "") return null;
|
||||
return {
|
||||
name,
|
||||
session: parts[1]!,
|
||||
name: line.slice(0, separator),
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,14 +382,35 @@ export class TmuxAdapter {
|
||||
return this.deliveryGuard ? this.deliveryGuard.operation(target, fn) : fn();
|
||||
}
|
||||
|
||||
constructor(private exec: ExecFn, private fileOps: TmuxFileOps = defaultTmuxFileOps()) {}
|
||||
constructor(
|
||||
private exec: ExecFn,
|
||||
private fileOps: TmuxFileOps = defaultTmuxFileOps(),
|
||||
private argvExec?: ArgvExecFn,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Run a multiplexer command. `argv` is what is EXECUTED on both paths:
|
||||
* elements are verbatim (no quoting layer) when argvExec is wired;
|
||||
* otherwise they are POSIX-joined — byte-identical to the legacy
|
||||
* template strings for all current call sites (the suite pins them).
|
||||
* `legacy` overrides the joined form only where history quoted
|
||||
* differently (the `-F "..."` double-quoted formats); pass it ONLY to
|
||||
* preserve an exact legacy string, never to smuggle new semantics.
|
||||
*/
|
||||
private async run(argv: string[], legacy?: string): Promise<string> {
|
||||
if (this.argvExec) return this.argvExec(argv);
|
||||
return this.exec(legacy ?? posixJoinArgv(argv));
|
||||
}
|
||||
|
||||
/** Start an empty native terminal server, without inventing a seat/session. */
|
||||
async startServer(): Promise<TmuxResult> {
|
||||
const probeName = `openrig-startup-${randomUUID()}`;
|
||||
try {
|
||||
if ((await this.probeSession(probeName)).state !== "transport_unavailable") return { ok: true };
|
||||
// tmux -D keeps an empty server alive. Native socket ownership arbitrates
|
||||
// tmux -D keeps an empty server alive. Stays on the shell-string path
|
||||
// deliberately: detach + stdio redirection have no argv equivalent,
|
||||
// and server bootstrap is POSIX-specific (psmux auto-starts its own).
|
||||
// Native socket ownership arbitrates
|
||||
// concurrent starts; the readback below, not shell exit, proves availability.
|
||||
await this.exec("tmux -D </dev/null >/dev/null 2>&1 &");
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
@@ -372,7 +425,8 @@ export class TmuxAdapter {
|
||||
|
||||
async listSessions(): Promise<TmuxSession[]> {
|
||||
try {
|
||||
const output = await this.exec(`tmux list-sessions -F "${SESSION_FORMAT}"`);
|
||||
const output = await this.run(["tmux", "list-sessions", "-F", SESSION_FORMAT],
|
||||
`tmux list-sessions -F "${SESSION_FORMAT}"`);
|
||||
return parseLines(output, parseSessionLine);
|
||||
} catch (err) {
|
||||
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
|
||||
@@ -382,7 +436,8 @@ export class TmuxAdapter {
|
||||
|
||||
async listWindows(sessionName: string): Promise<TmuxWindow[]> {
|
||||
try {
|
||||
const output = await this.exec(`tmux list-windows -t ${shellQuote(sessionName)} -F "${WINDOW_FORMAT}"`);
|
||||
const output = await this.run(["tmux", "list-windows", "-t", sessionName, "-F", WINDOW_FORMAT],
|
||||
`tmux list-windows -t ${shellQuote(sessionName)} -F "${WINDOW_FORMAT}"`);
|
||||
return parseLines(output, parseWindowLine);
|
||||
} catch (err) {
|
||||
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
|
||||
@@ -392,7 +447,8 @@ export class TmuxAdapter {
|
||||
|
||||
async listPanes(target: string): Promise<TmuxPane[]> {
|
||||
try {
|
||||
const output = await this.exec(`tmux list-panes -t ${shellQuote(target)} -F "${PANE_FORMAT}"`);
|
||||
const output = await this.run(["tmux", "list-panes", "-t", target, "-F", PANE_FORMAT],
|
||||
`tmux list-panes -t ${shellQuote(target)} -F "${PANE_FORMAT}"`);
|
||||
return parseLines(output, parsePaneLine);
|
||||
} catch (err) {
|
||||
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
|
||||
@@ -415,7 +471,8 @@ export class TmuxAdapter {
|
||||
// Use `tmux has-session` directly for reliable existence check — avoids
|
||||
// parsing format-string output from `list-sessions` which can fail when
|
||||
// tab delimiters are malformed across tmux versions.
|
||||
await this.exec(`tmux has-session -t ${shellQuote(name)}`);
|
||||
await this.run(["tmux", "has-session", "-t", name],
|
||||
`tmux has-session -t ${shellQuote(name)}`);
|
||||
return { state: "present" }; // exit 0 = session exists
|
||||
} catch (err) {
|
||||
if (isSessionAbsenceError(err)) {
|
||||
@@ -457,13 +514,14 @@ export class TmuxAdapter {
|
||||
finishLaunchBinding(session: string): void { this.freshManaged.delete(session); }
|
||||
|
||||
private async createSessionUnchecked(name: string, cwd?: string, env?: Record<string, string>): Promise<TmuxResult> {
|
||||
const cwdFlag = cwd != null ? ` -c ${shellQuote(cwd)}` : "";
|
||||
const envFlags = env
|
||||
? Object.entries(env).map(([k, v]) => ` -e ${shellQuote(`${k}=${v}`)}`).join("")
|
||||
: "";
|
||||
const cmd = `tmux new-session -d -s ${shellQuote(name)}${cwdFlag}${envFlags}`;
|
||||
const argv = ["tmux", "new-session", "-d", "-s", name];
|
||||
if (cwd != null) argv.push("-c", cwd);
|
||||
if (env) for (const [k, v] of Object.entries(env)) argv.push("-e", `${k}=${v}`);
|
||||
const legacyParts = ["tmux", "new-session", "-d", "-s", shellQuote(name)];
|
||||
if (cwd != null) legacyParts.push("-c", shellQuote(cwd));
|
||||
if (env) for (const [k, v] of Object.entries(env)) legacyParts.push("-e", shellQuote(`${k}=${v}`));
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(argv, legacyParts.join(" "));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -495,17 +553,20 @@ export class TmuxAdapter {
|
||||
let bufferLoaded = false;
|
||||
try {
|
||||
await this.fileOps.writeFile(path, text);
|
||||
await this.exec(`tmux load-buffer -b ${shellQuote(buffer)} ${shellQuote(path)}`);
|
||||
await this.run(["tmux", "load-buffer", "-b", buffer, path],
|
||||
`tmux load-buffer -b ${shellQuote(buffer)} ${shellQuote(path)}`);
|
||||
bufferLoaded = true;
|
||||
beforeWrite();
|
||||
await this.exec(`tmux paste-buffer -t ${shellQuote(target)} -b ${shellQuote(buffer)} -d -r -p`);
|
||||
await this.run(["tmux", "paste-buffer", "-t", target, "-b", buffer, "-d", "-r", "-p"],
|
||||
`tmux paste-buffer -t ${shellQuote(target)} -b ${shellQuote(buffer)} -d -r -p`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (bufferLoaded) {
|
||||
// paste failed after load - `-d` never ran, so the buffer is still
|
||||
// resident. Best-effort delete to avoid leaking it.
|
||||
try {
|
||||
await this.exec(`tmux delete-buffer -b ${shellQuote(buffer)}`);
|
||||
await this.run(["tmux", "delete-buffer", "-b", buffer],
|
||||
`tmux delete-buffer -b ${shellQuote(buffer)}`);
|
||||
} catch { /* best-effort cleanup */ }
|
||||
}
|
||||
return classifyWriteError(err);
|
||||
@@ -568,9 +629,9 @@ export class TmuxAdapter {
|
||||
}
|
||||
|
||||
private async sendKeysUnchecked(target: string, keys: string[]): Promise<TmuxResult> {
|
||||
const cmd = `tmux send-keys -t ${shellQuote(target)} ${keys.map(shellQuote).join(" ")}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "send-keys", "-t", target, ...keys],
|
||||
`tmux send-keys -t ${shellQuote(target)} ${keys.map(shellQuote).join(" ")}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -578,9 +639,9 @@ export class TmuxAdapter {
|
||||
}
|
||||
|
||||
async setWindowOption(target: string, option: string, value: string): Promise<TmuxResult> {
|
||||
const cmd = `tmux set-option -w -t ${shellQuote(target)} ${shellQuote(option)} ${shellQuote(value)}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "set-option", "-w", "-t", target, option, value],
|
||||
`tmux set-option -w -t ${shellQuote(target)} ${shellQuote(option)} ${shellQuote(value)}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -594,9 +655,8 @@ export class TmuxAdapter {
|
||||
if (!Number.isFinite(rows) || !Number.isInteger(rows) || rows < 1) {
|
||||
return { ok: false, code: "validation_error", message: `resizeWindow: rows must be a positive integer, got ${rows}` };
|
||||
}
|
||||
const cmd = `tmux resize-window -t ${shellQuote(target)} -x ${cols} -y ${rows}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "resize-window", "-t", target, "-x", String(cols), "-y", String(rows)]);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -606,7 +666,7 @@ export class TmuxAdapter {
|
||||
async killSession(name: string): Promise<TmuxResult> {
|
||||
if (this.deliveryGuard) {
|
||||
return this.guardedInput(name, async pane => {
|
||||
const stdout = await this.exec(`tmux display-message -p -t ${shellQuote(pane)} '#{session_id}'`);
|
||||
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}"], `tmux display-message -p -t ${shellQuote(pane)} '#{session_id}'`);
|
||||
const sessionId = stdout.trim();
|
||||
if (!/^\$\d+$/.test(sessionId)) return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
|
||||
const kill = async () => {
|
||||
@@ -622,9 +682,9 @@ export class TmuxAdapter {
|
||||
}
|
||||
|
||||
private async killSessionUnchecked(name: string): Promise<TmuxResult> {
|
||||
const cmd = `tmux kill-session -t ${shellQuote(name)}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "kill-session", "-t", name],
|
||||
`tmux kill-session -t ${shellQuote(name)}`);
|
||||
const pane = this.freshProbes.get(name);
|
||||
this.freshProbes.delete(name);
|
||||
if (pane) this.freshProbes.delete(pane);
|
||||
@@ -661,14 +721,17 @@ export class TmuxAdapter {
|
||||
}
|
||||
|
||||
private async respawnPaneUnchecked(paneTarget: string, command?: string, opts?: { cwd?: string; env?: Record<string, string> }): Promise<TmuxResult> {
|
||||
const cwdFlag = opts?.cwd != null ? ` -c ${shellQuote(opts.cwd)}` : "";
|
||||
const envFlags = opts?.env
|
||||
? Object.entries(opts.env).map(([k, v]) => ` -e ${shellQuote(`${k}=${v}`)}`).join("")
|
||||
: "";
|
||||
const commandArg = command != null && command.length > 0 ? ` ${shellQuote(command)}` : "";
|
||||
const cmd = `tmux respawn-pane -t ${shellQuote(paneTarget)}${cwdFlag}${envFlags}${commandArg}`;
|
||||
const argv = ["tmux", "respawn-pane", "-t", paneTarget];
|
||||
if (opts?.cwd != null) argv.push("-c", opts.cwd);
|
||||
if (opts?.env) for (const [k, v] of Object.entries(opts.env)) argv.push("-e", `${k}=${v}`);
|
||||
// The respawn command is ONE argv unit: tmux runs it via the shell.
|
||||
if (command != null && command.length > 0) argv.push(command);
|
||||
const legacyRespawn = ["tmux", "respawn-pane", "-t", shellQuote(paneTarget)];
|
||||
if (opts?.cwd != null) legacyRespawn.push("-c", shellQuote(opts.cwd));
|
||||
if (opts?.env) for (const [k, v] of Object.entries(opts.env)) legacyRespawn.push("-e", shellQuote(`${k}=${v}`));
|
||||
if (command != null && command.length > 0) legacyRespawn.push(shellQuote(command));
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(argv, legacyRespawn.join(" "));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -680,9 +743,9 @@ export class TmuxAdapter {
|
||||
* Set to `on` BEFORE the retiree is signalled to exit (else the pane is destroyed on exit and there
|
||||
* is nothing to respawn into). */
|
||||
async setRemainOnExit(paneTarget: string, on: boolean): Promise<TmuxResult> {
|
||||
const cmd = `tmux set-option -p -t ${shellQuote(paneTarget)} remain-on-exit ${on ? "on" : "off"}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "set-option", "-p", "-t", paneTarget, "remain-on-exit", on ? "on" : "off"],
|
||||
`tmux set-option -p -t ${shellQuote(paneTarget)} remain-on-exit ${on ? "on" : "off"}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -693,7 +756,8 @@ export class TmuxAdapter {
|
||||
* remain-on-exit)? A known-missing pane also proves physical cutover; unknown probe errors stay false. */
|
||||
async isPaneDead(paneId: string): Promise<boolean> {
|
||||
try {
|
||||
const output = await this.exec(`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_dead}"`);
|
||||
const output = await this.run(["tmux", "display-message", "-p", "-t", paneId, "#{pane_dead}"],
|
||||
`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_dead}"`);
|
||||
return output.trim() === "1";
|
||||
} catch (error) {
|
||||
return isNoServerError(error) || isPaneAbsenceError(error);
|
||||
@@ -702,7 +766,9 @@ export class TmuxAdapter {
|
||||
|
||||
/** Seat-handover cutover: signal the pane's foreground process (the retiree) — `TERM` for the graceful
|
||||
* exit-in-place, `KILL` for the bounded-timeout force fallback. Resolves the pane pid then `kill`s it;
|
||||
* an unresolvable pid is a structured, non-throwing failure. */
|
||||
* an unresolvable pid is a structured, non-throwing failure.
|
||||
* Stays on the shell-string path deliberately: this is a Unix `kill`,
|
||||
* not a multiplexer command (Windows needs taskkill — named follow-up). */
|
||||
async signalPaneProcess(paneId: string, signal: "TERM" | "KILL"): Promise<TmuxResult> {
|
||||
return this.guardedInput(paneId, (pane, beforeWrite) => this.signalPaneProcessUnchecked(pane, signal, beforeWrite));
|
||||
}
|
||||
@@ -724,7 +790,8 @@ export class TmuxAdapter {
|
||||
/** Get the PID of the foreground process in a pane. Returns null if unavailable. */
|
||||
async getPanePid(paneId: string): Promise<number | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_pid}"`);
|
||||
const output = await this.run(["tmux", "display-message", "-p", "-t", paneId, "#{pane_pid}"],
|
||||
`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_pid}"`);
|
||||
const trimmed = output.trim();
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
@@ -738,7 +805,7 @@ export class TmuxAdapter {
|
||||
* command the pane was created with. Returns null if unavailable (caller falls back). */
|
||||
async getDefaultShell(): Promise<string | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux show-options -gv default-shell`);
|
||||
const output = await this.run(["tmux", "show-options", "-gv", "default-shell"]);
|
||||
const trimmed = output.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
@@ -749,7 +816,8 @@ export class TmuxAdapter {
|
||||
/** Get the current foreground command in a pane. Returns null if unavailable. */
|
||||
async getPaneCommand(paneId: string): Promise<string | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_current_command}"`);
|
||||
const output = await this.run(["tmux", "display-message", "-p", "-t", paneId, "#{pane_current_command}"],
|
||||
`tmux display-message -p -t ${shellQuote(paneId)} "#{pane_current_command}"`);
|
||||
const trimmed = output.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
@@ -764,7 +832,8 @@ export class TmuxAdapter {
|
||||
* nonzero lookup exit. */
|
||||
async hasSessionEnv(sessionName: string, varName: string): Promise<boolean | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux show-environment -t ${shellQuote(sessionName)}`);
|
||||
const output = await this.run(["tmux", "show-environment", "-t", sessionName],
|
||||
`tmux show-environment -t ${shellQuote(sessionName)}`);
|
||||
const prefix = `${varName}=`;
|
||||
return output.split(/\r?\n/).some(
|
||||
(line) => line.startsWith(prefix) && line.slice(prefix.length).trim().length > 0,
|
||||
@@ -779,9 +848,13 @@ export class TmuxAdapter {
|
||||
// Shell-quote the path for safe injection into the pipe-pane command.
|
||||
// The entire pipe command is passed as a single argument to tmux,
|
||||
// which executes it via sh -c. We use shellQuote on the path.
|
||||
const cmd = `tmux pipe-pane -t ${shellQuote(sessionName)} ${shellQuote("cat >> " + shellQuote(outputPath))}`;
|
||||
// The sink travels as ONE argv unit: the multiplexer runs it via a
|
||||
// shell, so its inner quoting stays POSIX here (psmux documents the same
|
||||
// `cat >` server-side form for its own path).
|
||||
const sink = "cat >> " + shellQuote(outputPath);
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "pipe-pane", "-t", sessionName, sink],
|
||||
`tmux pipe-pane -t ${shellQuote(sessionName)} ${shellQuote(sink)}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -790,9 +863,9 @@ export class TmuxAdapter {
|
||||
|
||||
/** Stop pipe-pane on a session. */
|
||||
async stopPipePane(sessionName: string): Promise<TmuxResult> {
|
||||
const cmd = `tmux pipe-pane -t ${shellQuote(sessionName)}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "pipe-pane", "-t", sessionName],
|
||||
`tmux pipe-pane -t ${shellQuote(sessionName)}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -802,7 +875,8 @@ export class TmuxAdapter {
|
||||
/** Capture pane content (last N lines). Returns null if unavailable. */
|
||||
async capturePaneContent(paneId: string, lines: number = 20): Promise<string | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux capture-pane -p -t ${shellQuote(paneId)} -S -${lines}`);
|
||||
const output = await this.run(["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
|
||||
`tmux capture-pane -p -t ${shellQuote(paneId)} -S -${lines}`);
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -817,7 +891,8 @@ export class TmuxAdapter {
|
||||
*/
|
||||
async capturePaneScreen(paneId: string): Promise<string | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux capture-pane -p -t ${shellQuote(paneId)}`);
|
||||
const output = await this.run(["tmux", "capture-pane", "-p", "-t", paneId],
|
||||
`tmux capture-pane -p -t ${shellQuote(paneId)}`);
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -832,10 +907,11 @@ export class TmuxAdapter {
|
||||
*/
|
||||
async getPaneCursorPosition(paneId: string): Promise<TmuxCursorPosition | null> {
|
||||
try {
|
||||
const output = await this.exec(
|
||||
`tmux display-message -p -t ${shellQuote(paneId)} "#{cursor_x}\t#{cursor_y}\t#{pane_width}\t#{pane_height}"`,
|
||||
const output = await this.run(
|
||||
["tmux", "display-message", "-p", "-t", paneId, CURSOR_FORMAT],
|
||||
`tmux display-message -p -t ${shellQuote(paneId)} "${CURSOR_FORMAT}"`,
|
||||
);
|
||||
const [xRaw, yRaw, widthRaw, heightRaw] = output.trim().split("\t");
|
||||
const [xRaw, yRaw, widthRaw, heightRaw] = output.trim().split(TMUX_FIELD_SEPARATOR);
|
||||
const x = Number.parseInt(xRaw ?? "", 10);
|
||||
const y = Number.parseInt(yRaw ?? "", 10);
|
||||
const width = Number.parseInt(widthRaw ?? "", 10);
|
||||
@@ -857,9 +933,9 @@ export class TmuxAdapter {
|
||||
* scopes are never crossed (guard b2).
|
||||
*/
|
||||
async setSessionOption(sessionName: string, key: string, value: string): Promise<TmuxResult> {
|
||||
const cmd = `tmux set-option -t ${shellQuote(sessionName)} ${shellQuote(key)} ${shellQuote(value)}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "set-option", "-t", sessionName, key, value],
|
||||
`tmux set-option -t ${shellQuote(sessionName)} ${shellQuote(key)} ${shellQuote(value)}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -874,9 +950,9 @@ export class TmuxAdapter {
|
||||
* Used for `set-clipboard` / `copy-command`.
|
||||
*/
|
||||
async setServerOption(option: string, value: string): Promise<TmuxResult> {
|
||||
const cmd = `tmux set-option -s ${shellQuote(option)} ${shellQuote(value)}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "set-option", "-s", option, value],
|
||||
`tmux set-option -s ${shellQuote(option)} ${shellQuote(value)}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
@@ -890,7 +966,8 @@ export class TmuxAdapter {
|
||||
*/
|
||||
async showServerOption(option: string): Promise<string | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux show-options -sv ${shellQuote(option)}`);
|
||||
const output = await this.run(["tmux", "show-options", "-sv", option],
|
||||
`tmux show-options -sv ${shellQuote(option)}`);
|
||||
const v = output.trim();
|
||||
return v.length > 0 ? v : null;
|
||||
} catch {
|
||||
@@ -901,7 +978,8 @@ export class TmuxAdapter {
|
||||
/** Get a session-scoped user option value. Returns null if not set or error. */
|
||||
async getSessionOption(sessionName: string, key: string): Promise<string | null> {
|
||||
try {
|
||||
const output = await this.exec(`tmux show-option -v -t ${shellQuote(sessionName)} ${shellQuote(key)}`);
|
||||
const output = await this.run(["tmux", "show-option", "-v", "-t", sessionName, key],
|
||||
`tmux show-option -v -t ${shellQuote(sessionName)} ${shellQuote(key)}`);
|
||||
return output.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -931,7 +1009,8 @@ export class TmuxAdapter {
|
||||
*/
|
||||
async readPaneLastActivity(paneId: string): Promise<number | null> {
|
||||
try {
|
||||
const output = await this.exec(
|
||||
const output = await this.run(
|
||||
["tmux", "display-message", "-p", "-t", paneId, "#{window_activity}"],
|
||||
`tmux display-message -p -t ${shellQuote(paneId)} '#{window_activity}'`,
|
||||
);
|
||||
const trimmed = output.trim();
|
||||
@@ -954,7 +1033,8 @@ export class TmuxAdapter {
|
||||
*/
|
||||
async listClients(): Promise<TmuxClient[]> {
|
||||
try {
|
||||
const output = await this.exec(`tmux list-clients -F "${CLIENT_FORMAT}"`);
|
||||
const output = await this.run(["tmux", "list-clients", "-F", CLIENT_FORMAT],
|
||||
`tmux list-clients -F "${CLIENT_FORMAT}"`);
|
||||
return parseLines(output, parseClientLine);
|
||||
} catch (err) {
|
||||
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
|
||||
@@ -969,9 +1049,9 @@ export class TmuxAdapter {
|
||||
* kills, or rebinds a session and never touches OpenRig routing/identity.
|
||||
*/
|
||||
async switchClient(client: string, target: string): Promise<TmuxResult> {
|
||||
const cmd = `tmux switch-client -c ${shellQuote(client)} -t ${shellQuote(target)}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
await this.run(["tmux", "switch-client", "-c", client, "-t", target],
|
||||
`tmux switch-client -c ${shellQuote(client)} -t ${shellQuote(target)}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
|
||||
@@ -680,7 +680,7 @@ export class BootstrapOrchestrator {
|
||||
stages,
|
||||
rigId: (outcome as { rigId: string }).rigId,
|
||||
errors: [attentionMsg],
|
||||
warnings,
|
||||
warnings: [...warnings, ...((outcome as { warnings?: string[] }).warnings ?? [])],
|
||||
};
|
||||
}
|
||||
const outErrors = outcome.code === "validation_failed" || outcome.code === "preflight_failed"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,8 +49,11 @@ export class ClaudeManagedLaunch {
|
||||
// Relative/empty PATH entries are interpreted at the intended seat cwd,
|
||||
// including for /usr/bin/env shebangs inside the selected executable.
|
||||
const search = PATH.split(path.delimiter).map(p => path.resolve(cwd, p));
|
||||
const env: Record<string, string> = { PATH: search.join(path.delimiter), HOME,
|
||||
CLAUDE_CONFIG_DIR: path.resolve(cwd, CLAUDE_CONFIG_DIR ?? path.join(HOME, ".claude")) };
|
||||
const configDir = path.resolve(cwd, CLAUDE_CONFIG_DIR ?? path.join(HOME, ".claude"));
|
||||
const env: Record<string, string> = { PATH: search.join(path.delimiter), HOME };
|
||||
// Session storage needs a directory, but exporting the default changes
|
||||
// Claude's global config selection. Preserve an unset native selection.
|
||||
if (CLAUDE_CONFIG_DIR !== undefined) env.CLAUDE_CONFIG_DIR = configDir;
|
||||
if (claudeClassicRendererEnvPrefix(this.rendererEnv)) env.CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = "1";
|
||||
let executable: string | undefined;
|
||||
for (const dir of search) {
|
||||
@@ -64,7 +67,7 @@ export class ClaudeManagedLaunch {
|
||||
const s = statSync(file);
|
||||
return [realpathSync(file), s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs];
|
||||
};
|
||||
return Object.freeze({ env: Object.freeze(env), executable,
|
||||
return Object.freeze({ env: Object.freeze(env), configDir, executable,
|
||||
fileIdentity: Object.freeze(identity(executable)), cwdIdentity: Object.freeze(identity(cwd).slice(0, 3)) });
|
||||
}
|
||||
|
||||
@@ -108,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.env.CLAUDE_CONFIG_DIR!, command: (args: readonly string[]) => {
|
||||
return Object.freeze({ assertCurrent, configDir: context.configDir, command: (args: readonly string[]) => {
|
||||
assertCurrent();
|
||||
return `cd ${shellQuote(cwd)} && /usr/bin/env -i ${[...assignments, ...forwarded, shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}`;
|
||||
} });
|
||||
|
||||
@@ -51,7 +51,8 @@ export class DiscoveryRepository {
|
||||
`UPDATE discovered_sessions SET
|
||||
tmux_window = ?, pid = ?, cwd = ?, active_command = ?,
|
||||
runtime_hint = ?, confidence = ?, evidence_json = ?, config_json = ?,
|
||||
last_seen_at = datetime('now'), status = 'active'
|
||||
last_seen_at = datetime('now'),
|
||||
status = CASE WHEN status = 'claimed' THEN 'claimed' ELSE 'active' END
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
data.tmuxWindow ?? null, data.pid ?? null, data.cwd ?? null, data.activeCommand ?? null,
|
||||
|
||||
@@ -146,6 +146,8 @@ export class FileWriteService {
|
||||
try {
|
||||
tmpFd = fs.openSync(tmpPath, "w");
|
||||
fs.writeFileSync(tmpFd, req.content);
|
||||
// Replacing the inode must preserve the existing file's ordinary permissions.
|
||||
fs.fchmodSync(tmpFd, prevStat.mode & 0o777);
|
||||
fs.fsyncSync(tmpFd);
|
||||
} catch (err) {
|
||||
// Clean up temp file if it was partially created.
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
// rather than "daemon failed to start".
|
||||
|
||||
import nodePath from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { BootstrapOrchestrator } from "./bootstrap-orchestrator.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
@@ -168,14 +170,72 @@ export async function defaultProbeRuntimes(): Promise<RuntimeProbeResult> {
|
||||
}
|
||||
}
|
||||
|
||||
const codexHome = process.env.CODEX_HOME || nodePath.join(os.homedir(), ".codex");
|
||||
const [claudeCode, codex] = await Promise.all([
|
||||
tryProbe("claude auth status"),
|
||||
tryProbe("codex login status"),
|
||||
probeCodexReadiness({
|
||||
run: tryProbe,
|
||||
readConfig: () => {
|
||||
try { return readFileSync(nodePath.join(codexHome, "config.toml"), "utf-8"); } catch { return null; }
|
||||
},
|
||||
env: process.env,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { claudeCode, codex };
|
||||
}
|
||||
|
||||
/** Issue #194 — how the Codex provider selected in `$CODEX_HOME/config.toml`
|
||||
* authenticates. Only an explicit provider entry with
|
||||
* `requires_openai_auth = false` and an `env_key` uses its credential
|
||||
* variable. Everything else (no file, a parse error, a legacy top-level
|
||||
* `profile`, a built-in or undeclared provider) keeps the OpenAI login check,
|
||||
* so an unresolved config does not take the env-key shortcut; readiness still follows the login result. Other Codex config layers
|
||||
* (project, system or managed config, requirements, `-c`, `--profile`) are not
|
||||
* resolved here. The CLI's `rig setup` carries the same rule. */
|
||||
export type CodexProviderAuth =
|
||||
| { kind: "openai-login"; unresolved?: string }
|
||||
| { kind: "env-key"; providerId: string; envKey: string };
|
||||
|
||||
export function selectCodexProviderAuth(configToml: string | null): CodexProviderAuth {
|
||||
if (configToml === null) return { kind: "openai-login" };
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = parseToml(configToml) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { kind: "openai-login", unresolved: "config.toml could not be parsed" };
|
||||
}
|
||||
if (Object.hasOwn(config, "profile")) {
|
||||
return { kind: "openai-login", unresolved: "config.toml selects a legacy profile, which is not resolved here" };
|
||||
}
|
||||
const providerId = config["model_provider"];
|
||||
const providers = config["model_providers"];
|
||||
if (typeof providerId !== "string" || !providers || typeof providers !== "object" || !Object.hasOwn(providers, providerId)) {
|
||||
return { kind: "openai-login" };
|
||||
}
|
||||
const entry = (providers as Record<string, unknown>)[providerId];
|
||||
if (!entry || typeof entry !== "object") return { kind: "openai-login" };
|
||||
const { requires_openai_auth: requiresOpenAiAuth, env_key: envKey } = entry as Record<string, unknown>;
|
||||
if (requiresOpenAiAuth !== false || typeof envKey !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) {
|
||||
return { kind: "openai-login" };
|
||||
}
|
||||
return { kind: "env-key", providerId, envKey };
|
||||
}
|
||||
|
||||
/** Codex half of the kernel runtime probe. An env-key provider is ready when
|
||||
* its variable is nonblank and the executable answers; this is local
|
||||
* credential availability, not a remote authentication or inference check. */
|
||||
export async function probeCodexReadiness(deps: {
|
||||
run: (cmd: string) => Promise<RuntimeAuthStatus>;
|
||||
readConfig: () => string | null;
|
||||
env: Record<string, string | undefined>;
|
||||
}): Promise<RuntimeAuthStatus> {
|
||||
const auth = selectCodexProviderAuth(deps.readConfig());
|
||||
if (auth.kind === "openai-login") return deps.run("codex login status");
|
||||
if (!deps.env[auth.envKey]?.trim()) return "unavailable";
|
||||
return deps.run("codex --version");
|
||||
}
|
||||
|
||||
/** Honest 3-part-error message for the auth-block path. Per IMPL-PRD
|
||||
* §6.3 + the building-agent-software skill discipline. */
|
||||
export function authBlockMessage(): string {
|
||||
|
||||
@@ -116,7 +116,8 @@ function scanHeaders(lines: string[]): HeaderScan {
|
||||
const length = fenceMatch[1]!.length;
|
||||
if (!fence) {
|
||||
fence = { marker, length, line: i };
|
||||
} else if (fence.marker === marker && length >= fence.length) {
|
||||
} else if (fence.marker === marker && length >= fence.length
|
||||
&& /^[ \t\r]*$/.test(line.slice(fenceMatch[0].length))) {
|
||||
fence = null;
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -121,11 +121,17 @@ export function findExactNativeResumeProcess(
|
||||
export async function listNativeProcesses(): Promise<NativeProcessRow[]> {
|
||||
try {
|
||||
const output = await runAsyncSite("codex.runtime.list_processes", async () => {
|
||||
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,pgid,tpgid,ucomm,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 });
|
||||
// lstart is locale-formatted; the child-only C locale keeps the English date the parser expects.
|
||||
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,pgid,tpgid,ucomm,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } });
|
||||
return stdout;
|
||||
});
|
||||
return output.split("\n").slice(1).flatMap((line) => {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/);
|
||||
// ucomm may contain spaces on every platform: macOS app helpers (`Slack Helper`), and on
|
||||
// Linux task names set by prctl(PR_SET_NAME) or process.title (`tmux: server`,
|
||||
// `node (vitest 1)`). lstart always begins with a weekday word and runs to the year, and
|
||||
// ucomm (16 bytes at most) is too short to contain such a date, so matching ucomm lazily
|
||||
// up to the first date is exact.
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(.+?)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/);
|
||||
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), tpgid: Number(match[4]), executableName: match[5]!, startedAt: match[6]!, command: match[7]! }] : [];
|
||||
});
|
||||
} catch { return []; }
|
||||
|
||||
@@ -30,10 +30,11 @@ export interface OccupantInvalidatorDeps {
|
||||
/** (e/Class-B) durable watchdog_jobs store — armed jobs registered by the retiring generation are
|
||||
* stopped at swap (a stale wake into the successor's context is the ghost). Optional: absent ⇒ the
|
||||
* watchdog branch is skipped (never a name-scoped fallback). */
|
||||
watchdog?: { dropArmedByRegisteringGeneration(generationUuid: string): number };
|
||||
watchdog?: { dropArmedByRegisteringGeneration(generationUuid: string, keepJobIds?: readonly string[]): number };
|
||||
/** (e/Class-B) durable queue_items store — in-progress items claimed by the retiring generation are
|
||||
* RELEASED to pending (never dropped: the role work is durable, the successor re-claims). Optional. */
|
||||
queue?: { releaseClaimsByGeneration(generationUuid: string): number };
|
||||
* RELEASED to pending (never dropped: the role work is durable, the successor re-claims). Its current
|
||||
* park timers (blocked rows' wakes) are role work too, so the watchdog drop keeps them. Optional. */
|
||||
queue?: { releaseClaimsByGeneration(generationUuid: string): number; currentParkTimerIds?(): string[] };
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
@@ -65,8 +66,10 @@ export class DefaultOccupantInvalidator implements OccupantInvalidator {
|
||||
// atom-B present → Class-B gen-scoped invalidation.
|
||||
// Watchdog (3b): stop every ARMED job registered by the retiring generation — a stale wake firing
|
||||
// into the successor's context is the specimen; the successor re-arms its own. Gen-scoped so the
|
||||
// successor's OWN armed jobs (same name, live gen) are untouched.
|
||||
const stopped = this.deps.watchdog?.dropArmedByRegisteringGeneration(retiringGeneration) ?? 0;
|
||||
// successor's OWN armed jobs (same name, live gen) are untouched. A blocked row's current park timer
|
||||
// is kept: it wakes the seat that owns the row, and nothing re-arms it if it stops.
|
||||
const keepJobIds = this.deps.queue?.currentParkTimerIds?.() ?? [];
|
||||
const stopped = this.deps.watchdog?.dropArmedByRegisteringGeneration(retiringGeneration, keepJobIds) ?? 0;
|
||||
if (stopped > 0) {
|
||||
log(
|
||||
`[occupant-invalidator] Class-B: stopped ${stopped} armed watchdog job(s) registered by retired ` +
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
// Otherwise the grace follows the latest meaningful queue change; an old note
|
||||
// cannot keep the row working forever. Stalled-after-claim names this evidence
|
||||
// without inferring idle/dead from age. Blocked remains parked; wake health is
|
||||
// derived separately. Legacy callers without timestamps retain count semantics.
|
||||
// derived separately. A CLOSED row is terminal: the stall timer answers "is a
|
||||
// claimant holding this obligation", and no obligation survives closure — a
|
||||
// done item must never contradict its own waiting.state with stalled-after-claim.
|
||||
// Legacy callers without timestamps retain count semantics.
|
||||
// Queue-row last_heartbeat is formally superseded (2026-08-30, S24 F-14); readers remain
|
||||
// null-tolerant. Wiring reopens only for the 0.5.7 mechanized-pull turn-end hook that knows the in-flight row,
|
||||
// the first honest row-scoped writer. daemon-lifecycle-store.recordHeartbeat remains live and distinct.
|
||||
@@ -20,11 +23,17 @@ export const PICKUP_STALL_THRESHOLD_KEY = "queue.pickup_stall_threshold_minutes"
|
||||
export const DEFAULT_PICKUP_STALL_THRESHOLD_MINUTES = 3;
|
||||
|
||||
export interface PickupReceipt {
|
||||
state: "unclaimed" | "working" | "stalled-after-claim" | "parked";
|
||||
/** Present iff stalled: the named evidence replacing the manual cross-surface join. */
|
||||
state: "unclaimed" | "working" | "stalled-after-claim" | "parked" | "terminal";
|
||||
/** Present iff stalled or terminal: the named evidence replacing the manual cross-surface join. */
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
/** The closed states. The waiting view already reports every state outside
|
||||
* pending/in-progress/blocked as "none (terminal obligation)"; the pickup
|
||||
* receipt must not contradict it with a stall timer. `blocked` is NOT closed
|
||||
* (it reads parked, and its wake health is derived separately). */
|
||||
const CLOSED_PICKUP_STATES = ["done", "failed", "denied", "canceled", "handed-off"] as const;
|
||||
|
||||
/** Threshold, FRESH-READ per call (the terminal.status_bar precedent: a config flip applies
|
||||
* to the next read, no restart). Fail-open to the default on any resolution error. */
|
||||
export function resolvePickupThresholdMinutes(): number {
|
||||
@@ -54,6 +63,9 @@ export interface PickupFacts {
|
||||
* S02 finding input) calls this same function, so the rule cannot drift between surfaces. */
|
||||
export function derivePickup(facts: PickupFacts): PickupReceipt {
|
||||
if (facts.state === "blocked") return { state: "parked" };
|
||||
if ((CLOSED_PICKUP_STATES as readonly string[]).includes(facts.state)) {
|
||||
return { state: "terminal", evidence: `closed (${facts.state})` };
|
||||
}
|
||||
if (!facts.claimedAt) return { state: "unclaimed" };
|
||||
const now = facts.now ?? new Date();
|
||||
const claimedMs = Date.parse(facts.claimedAt);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type ParkWakeStatus,
|
||||
} from "./queue-wake-repository.js";
|
||||
import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js";
|
||||
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait } from "./queue-wait-backoff.js";
|
||||
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js";
|
||||
|
||||
export const QUEUE_STATES = [
|
||||
"pending",
|
||||
@@ -130,8 +130,9 @@ export interface QueueItem {
|
||||
deliveryFailureDetail?: string;
|
||||
tags: string[] | null;
|
||||
blockedOn: string | null;
|
||||
/** S04 — the DERIVED pickup receipt (unclaimed/working/stalled-after-claim/parked). Never
|
||||
* stored: computed at projection time from claimed_at + the transition log + heartbeat. */
|
||||
/** S04 — the DERIVED pickup receipt (unclaimed/working/stalled-after-claim/parked/terminal).
|
||||
* Never stored: computed at projection time from state + claimed_at + the transition log
|
||||
* + heartbeat; a closed row is terminal, never stalled. */
|
||||
pickup?: PickupReceipt;
|
||||
waiting?: WaitingView;
|
||||
handedOffTo: string | null;
|
||||
@@ -2848,6 +2849,12 @@ export class QueueRepository {
|
||||
return this.wakeRepo.getStatus(qitemId);
|
||||
}
|
||||
|
||||
/** Park timers a seat swap keeps: each is still its blocked row's current wake and still targets the
|
||||
* row's owner (see QueueWakeRepository.currentParkTimerIds). */
|
||||
currentParkTimerIds(): string[] {
|
||||
return this.wakeRepo.currentParkTimerIds();
|
||||
}
|
||||
|
||||
/** Refuse a legacy park-generated timer only when every row bound to it is
|
||||
* terminal. Current exits retire these timers transactionally; this is the
|
||||
* delivery-seam backstop for residue persisted by an older daemon. A timer
|
||||
@@ -3384,6 +3391,12 @@ export class QueueRepository {
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(fallbackDestination, ts, newChain, `fallback: ${reason}`, qitemId);
|
||||
// A park timer targets the owner that parked the row. Once the row belongs to someone else it must not keep
|
||||
// waking the old owner, so it ends here like any other exit from the park. A repeating wait is left running:
|
||||
// it resolves its recipient from the row's current owner at delivery (evaluateQueueWait).
|
||||
const armed = this.wakeRepo.getStatus(qitemId);
|
||||
const armedJob = armed?.kind === "timer" ? (this.watchdogJobsRepo ?? new WatchdogJobsRepository(this.db)).getById(armed.ref) : null;
|
||||
if (!armedJob || !isQueueWait(armedJob.specYaml)) this.retireParkGeneratedTimer(qitemId, "park_rerouted");
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId,
|
||||
|
||||
@@ -174,6 +174,25 @@ export class QueueWakeRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** Active park-generated timers that are still their blocked row's current wake and still target the
|
||||
* row's owner. These wake the seat that owns the row, not a particular occupant, so a seat swap keeps
|
||||
* them; any other job the retiring occupant registered still stops. */
|
||||
currentParkTimerIds(): string[] {
|
||||
if (!this.available) return [];
|
||||
return this.db.prepare(
|
||||
`SELECT w.wake_ref
|
||||
FROM queue_transition_wakes w
|
||||
JOIN queue_items q ON q.qitem_id = w.qitem_id
|
||||
JOIN watchdog_jobs j ON j.job_id = w.wake_ref
|
||||
WHERE w.phase = 'armed' AND w.wake_kind = 'timer'
|
||||
AND q.state = 'blocked' AND j.state = 'active' AND j.target_session = q.destination_session
|
||||
AND w.transition_id = (
|
||||
SELECT MAX(a.transition_id) FROM queue_transition_wakes a
|
||||
WHERE a.qitem_id = w.qitem_id AND a.phase = 'armed'
|
||||
)`,
|
||||
).all().map((row) => (row as { wake_ref: string }).wake_ref);
|
||||
}
|
||||
|
||||
private isLive(kind: ParkWakeKind, ref: string): boolean {
|
||||
if (kind === "blocker") {
|
||||
const row = this.db.prepare("SELECT state FROM queue_items WHERE qitem_id = ?").get(ref) as
|
||||
|
||||
@@ -331,7 +331,8 @@ export async function defaultListProcessesStrict(): Promise<Array<{ pid: number;
|
||||
// lstart = the process START TIME — the identity half of pid+start-time
|
||||
// (r1's pid-reuse remedy): a reused pid changes lstart, so a consumer
|
||||
// holding last cycle's identity can invalidate without any extra spawn.
|
||||
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 });
|
||||
// lstart is locale-formatted; the child-only C locale keeps the English date the parser expects.
|
||||
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } });
|
||||
return stdout;
|
||||
});
|
||||
return output
|
||||
|
||||
@@ -533,8 +533,14 @@ export class RigLifecycleService {
|
||||
if (invalidFallback) return invalidFallback;
|
||||
}
|
||||
|
||||
// #141: the same ownership rule as removeNode, applied to the whole shrink target. A member's session name
|
||||
// that an unarchived node OUTSIDE the target owns (for example the live replacement of an archived
|
||||
// generation) addresses that node's work, which this shrink neither counts nor reroutes. Another member of
|
||||
// the target sharing the name doesn't take the name's work out of this check.
|
||||
const activeQitemIds = this.activeQitemIdsForSessionNames(
|
||||
nodes.flatMap((node) => node.latest_session_name ? [node.latest_session_name] : []),
|
||||
nodes.flatMap((node) => node.latest_session_name
|
||||
&& !findOtherSessionOwner(this.db, node.latest_session_name, node.id, { ignoreArchived: true, excludeNodeIds: ids })
|
||||
? [node.latest_session_name] : []),
|
||||
);
|
||||
if (activeQitemIds.length > 0 && fallbackDestination === undefined) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ResolvedPolicyAttachment,
|
||||
} from "./permission-policy/policy-ref.js";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import { checkRunningNameGuard, makeRunningSessionCounter } from "./running-name-guard.js";
|
||||
import { checkRunningNameGuard, confirmStoppedGenerations, makeRunningSessionCounter } from "./running-name-guard.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { NodeLauncher } from "./node-launcher.js";
|
||||
@@ -460,6 +460,16 @@ export type AddMemberOutcome =
|
||||
* Pod-aware rig instantiator. Creates pods, nodes, edges, and runs
|
||||
* startup orchestration per node with resolved agent specs.
|
||||
*/
|
||||
/** #141: rig names with a YAML import in progress, per daemon database. */
|
||||
const importsInFlight = new WeakMap<Database.Database, Set<string>>();
|
||||
|
||||
/** #141: the same-name rigs changed between the stopped check and the create transaction. */
|
||||
class GenerationChanged extends Error {
|
||||
constructor(name: string) {
|
||||
super(`The rigs named "${name}" changed while this import was checking them. Nothing was created; retry the import.`);
|
||||
}
|
||||
}
|
||||
|
||||
export class PodRigInstantiator {
|
||||
readonly db: Database.Database;
|
||||
private deps: PodInstantiatorDeps;
|
||||
@@ -1220,6 +1230,32 @@ export class PodRigInstantiator {
|
||||
}
|
||||
|
||||
async instantiate(rigSpecYaml: string, rigRoot: string, opts?: { cwdOverride?: string; force?: boolean; prelaunchHook?: (rigId: string) => Promise<{ ok: true } | { ok: false; code: string; message: string }> }): Promise<InstantiateOutcome> {
|
||||
// #141: while an import may archive a stopped same-name generation, allow one import per rig name at
|
||||
// a time on this daemon. Otherwise two imports could each replace it, or one could archive the other's
|
||||
// in-progress replacement. Unrelated names are unaffected; an adapter that cannot probe keeps today's
|
||||
// behavior.
|
||||
let name: string | null = null;
|
||||
if (this.deps.tmuxAdapter?.probeSession) {
|
||||
try {
|
||||
const parsed = PodRigSpecCodec.parse(rigSpecYaml) as { name?: unknown };
|
||||
name = typeof parsed?.name === "string" ? parsed.name : null;
|
||||
} catch { /* the import below reports the parse error */ }
|
||||
}
|
||||
if (!name) return this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
let inFlight = importsInFlight.get(this.db);
|
||||
if (!inFlight) importsInFlight.set(this.db, (inFlight = new Set()));
|
||||
if (inFlight.has(name)) {
|
||||
return { ok: false, code: "generation_unconfirmed", message: `Another import of rig "${name}" is in progress. Nothing was created; retry once it finishes.` };
|
||||
}
|
||||
inFlight.add(name);
|
||||
try {
|
||||
return await this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
} finally {
|
||||
inFlight.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
private async instantiateOnce(rigSpecYaml: string, rigRoot: string, opts?: { cwdOverride?: string; force?: boolean; prelaunchHook?: (rigId: string) => Promise<{ ok: true } | { ok: false; code: string; message: string }> }): Promise<InstantiateOutcome> {
|
||||
// 1. Parse + validate
|
||||
let rigSpec: PodRigSpec;
|
||||
try {
|
||||
@@ -1264,10 +1300,46 @@ export class PodRigInstantiator {
|
||||
}
|
||||
|
||||
// 4. Create rig (after cycle check passes)
|
||||
// #141: an explicit YAML import replacing a same-name rig archives the prior generations once they
|
||||
// are confirmed stopped, atomically with creating the replacement, so they stop claiming its seat
|
||||
// names. A same-name generation that can't be confirmed stopped is kept, and nothing is created.
|
||||
let archivedGenerations: string[] = [];
|
||||
const tmuxProbe = this.deps.tmuxAdapter?.probeSession?.bind(this.deps.tmuxAdapter);
|
||||
const priorGenerations = tmuxProbe ? this.deps.rigRepo.findUnarchivedRigsByName(rigSpec.name).map((rig) => rig.id) : [];
|
||||
if (priorGenerations.length > 0) {
|
||||
const stopped = await confirmStoppedGenerations(this.db, priorGenerations, tmuxProbe!);
|
||||
if (!stopped.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "generation_unconfirmed",
|
||||
message:
|
||||
`A rig named "${rigSpec.name}" already exists (${stopped.rigId}), and OpenRig could not confirm it is stopped: ` +
|
||||
`${stopped.reason}. Nothing was created. Restore it with 'rig up ${rigSpec.name}', ` +
|
||||
`or archive it first with 'rig archive ${stopped.rigId}'.`,
|
||||
};
|
||||
}
|
||||
archivedGenerations = stopped.rigIds;
|
||||
}
|
||||
const restoreArchived = () => { for (const id of archivedGenerations) this.deps.rigRepo.unarchiveRig(id); };
|
||||
|
||||
let rigId: string;
|
||||
let createdRigId: string | null = null;
|
||||
try {
|
||||
const rig = this.deps.rigRepo.createRig(rigSpec.name);
|
||||
const create = this.db.transaction(() => {
|
||||
if (tmuxProbe) {
|
||||
// #141: apply the stopped decision only to the same-name rigs it was made for, and archive
|
||||
// only rows this import changes, so a rollback restores exactly what it archived.
|
||||
const current = this.deps.rigRepo.findUnarchivedRigsByName(rigSpec.name).map((rig) => rig.id).sort();
|
||||
if (current.join(",") !== [...priorGenerations].sort().join(",")) throw new GenerationChanged(rigSpec.name);
|
||||
for (const id of archivedGenerations) {
|
||||
if (makeRunningSessionCounter(this.db)(id) > 0 || !this.deps.rigRepo.archiveRig(id)) throw new GenerationChanged(rigSpec.name);
|
||||
}
|
||||
}
|
||||
return this.deps.rigRepo.createRig(rigSpec.name);
|
||||
});
|
||||
const rig = create();
|
||||
rigId = rig.id;
|
||||
createdRigId = rig.id;
|
||||
// PL-007: persist typed workspace block (when declared) on the rig
|
||||
// record. Whoami / node-inventory read it via getRigWorkspace().
|
||||
if (rigSpec.workspace) {
|
||||
@@ -1293,6 +1365,9 @@ export class PodRigInstantiator {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof GenerationChanged) return { ok: false, code: "generation_unconfirmed", message: err.message };
|
||||
// #141: a replacement that failed to finish creating must not leave the prior generation hidden.
|
||||
if (createdRigId && archivedGenerations.length > 0) { this.deps.rigRepo.deleteRig(createdRigId); restoreArchived(); }
|
||||
return { ok: false, code: "instantiate_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
@@ -1301,6 +1376,9 @@ export class PodRigInstantiator {
|
||||
const nodeIdMap: Record<string, string> = {}; // "pod.member" -> node DB id
|
||||
const launchedSessionNames: string[] = []; // Track for orphan cleanup on total failure
|
||||
const podInstantiateWarnings = preflight.warnings;
|
||||
for (const id of archivedGenerations) {
|
||||
podInstantiateWarnings.push(`Archived the stopped earlier "${rigSpec.name}" rig ${id}; its records are kept. Restore it with: rig unarchive ${id}`);
|
||||
}
|
||||
|
||||
// OPR.0.5.3.6 (r2-B1) — instantiate() is the REAL rig-up door (/api/up →
|
||||
// bootstrap → here), and like the rig-persist sites above, BOTH
|
||||
@@ -1363,6 +1441,7 @@ export class PodRigInstantiator {
|
||||
const hookResult = await opts.prelaunchHook(rigId);
|
||||
if (!hookResult.ok) {
|
||||
this.deps.rigRepo.deleteRig(rigId);
|
||||
restoreArchived();
|
||||
return { ok: false, code: "service_boot_failed", message: hookResult.message };
|
||||
}
|
||||
}
|
||||
@@ -1537,6 +1616,7 @@ export class PodRigInstantiator {
|
||||
}
|
||||
}
|
||||
this.deps.rigRepo.deleteRig(rigId);
|
||||
restoreArchived();
|
||||
};
|
||||
const guard = this.deps.tmuxAdapter?.deliveryGuard;
|
||||
if (guard) await guard.lifecycle(Object.values(nodeIdMap), cleanup);
|
||||
@@ -1575,6 +1655,8 @@ export class PodRigInstantiator {
|
||||
message: `${attentionNodes.length} node${attentionNodes.length === 1 ? " requires" : "s require"} attention before becoming interactive. Inspect the affected sessions and reasons before choosing recovery.`,
|
||||
rigId,
|
||||
attentionNodes,
|
||||
// #141: the replacement is kept on this path, so its archive notice must reach the user too.
|
||||
...(archivedGenerations.length > 0 ? { warnings: podInstantiateWarnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,3 +72,48 @@ export function checkRunningNameGuard(deps: RunningNameGuardDeps, name: string):
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type StoppedGenerationsVerdict =
|
||||
| { ok: true; rigIds: string[] }
|
||||
| { ok: false; rigId: string; reason: string };
|
||||
|
||||
/**
|
||||
* #141: before an explicit YAML import creates a replacement generation, find the unarchived same-name
|
||||
* generations it may archive. Each must have no running sessions (the guard above) and, for every
|
||||
* session name it has used, this daemon's tmux must report positive absence or, on the same socket the
|
||||
* launch uses, "no server running". Any other probe result is uncertainty, not absence. This is a
|
||||
* generation-management rule for the daemon's own tmux namespace, not proof that no process is alive.
|
||||
* `probe` is undefined when the adapter cannot probe; the caller then keeps its existing behavior.
|
||||
*/
|
||||
export async function confirmStoppedGenerations(
|
||||
db: Database.Database,
|
||||
unarchivedRigIds: string[],
|
||||
probe: (sessionName: string) => Promise<{ state: string; cause?: string }>,
|
||||
): Promise<StoppedGenerationsVerdict> {
|
||||
const running = makeRunningSessionCounter(db);
|
||||
for (const rigId of unarchivedRigIds) {
|
||||
if (running(rigId) > 0) return { ok: false, rigId, reason: "it has a running session" };
|
||||
const names = (db.prepare(`
|
||||
SELECT b.tmux_session AS name FROM bindings b JOIN nodes n ON n.id = b.node_id WHERE n.rig_id = ? AND b.tmux_session IS NOT NULL
|
||||
UNION SELECT s.session_name FROM sessions s JOIN nodes n ON n.id = s.node_id WHERE n.rig_id = ?
|
||||
`).all(rigId, rigId) as Array<{ name: string }>).map((row) => row.name);
|
||||
for (const name of names) {
|
||||
let result: { state: string; cause?: string };
|
||||
try {
|
||||
result = await probe(name);
|
||||
} catch (err) {
|
||||
return { ok: false, rigId, reason: `tmux could not check session ${name}: ${(err as Error).message}` };
|
||||
}
|
||||
if (result.state === "absent") continue;
|
||||
if (result.state === "transport_unavailable" && result.cause?.includes("no server running")) continue;
|
||||
return {
|
||||
ok: false,
|
||||
rigId,
|
||||
reason: result.state === "present"
|
||||
? `tmux session ${name} still exists`
|
||||
: `tmux could not check session ${name}${result.cause ? `: ${result.cause}` : ""}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, rigIds: unarchivedRigIds };
|
||||
}
|
||||
@@ -655,7 +655,10 @@ export class SeatHandoverService {
|
||||
const runtimeMismatch = this.checkRuntimeMismatch(input.node.runtime, discovered.runtimeHint);
|
||||
if (runtimeMismatch) return fail(runtimeMismatch);
|
||||
|
||||
const managedOwner = this.lookupManagedOwner(discovered.tmuxSession, input.node.id);
|
||||
// #141: a composer-launched successor reuses the seat's own session name, which an archived earlier
|
||||
// generation still names in its kept binding. Owners in archived rigs are skipped there, as in removeNode
|
||||
// (#174). A discovered successor is a separate session, so any other owner still blocks it.
|
||||
const managedOwner = this.lookupManagedOwner(discovered.tmuxSession, input.node.id, input.reportedSource.mode !== "discovered");
|
||||
if (managedOwner) {
|
||||
return fail({
|
||||
ok: false,
|
||||
@@ -891,8 +894,8 @@ export class SeatHandoverService {
|
||||
).get(nodeId) as SessionRow | undefined ?? null;
|
||||
}
|
||||
|
||||
private lookupManagedOwner(tmuxSession: string, targetNodeId: string): BindingOwnerRow | null {
|
||||
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId);
|
||||
private lookupManagedOwner(tmuxSession: string, targetNodeId: string, ignoreArchived: boolean): BindingOwnerRow | null {
|
||||
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
|
||||
}
|
||||
|
||||
private commit(input: {
|
||||
|
||||
@@ -12,22 +12,26 @@ export interface SessionOwnerRow {
|
||||
* can be shared across rigs, for example by an archived duplicate of a live rig, so the name alone
|
||||
* never proves which node a session belongs to. `ignoreArchived` skips owners in archived rigs: an
|
||||
* archived rig keeps its bindings, so its node would otherwise claim the live seat's session.
|
||||
* `excludeNodeIds` skips further nodes, such as the rest of a group being removed together; it is applied
|
||||
* in both lookups before the first match is taken, so an excluded node can't hide another owner.
|
||||
*/
|
||||
export function findOtherSessionOwner(
|
||||
db: Database.Database,
|
||||
tmuxSession: string,
|
||||
nodeId: string,
|
||||
opts?: { ignoreArchived?: boolean },
|
||||
opts?: { ignoreArchived?: boolean; excludeNodeIds?: readonly string[] },
|
||||
): SessionOwnerRow | null {
|
||||
const live = opts?.ignoreArchived ? " AND r.archived_at IS NULL" : "";
|
||||
const excluded = [nodeId, ...(opts?.excludeNodeIds ?? [])];
|
||||
const notExcluded = `n.id NOT IN (${excluded.map(() => "?").join(", ")})`;
|
||||
const bindingOwner = db.prepare(`
|
||||
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
|
||||
FROM bindings b
|
||||
JOIN nodes n ON n.id = b.node_id
|
||||
JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE b.tmux_session = ? AND n.id != ?${live}
|
||||
WHERE b.tmux_session = ? AND ${notExcluded}${live}
|
||||
LIMIT 1
|
||||
`).get(tmuxSession, nodeId) as SessionOwnerRow | undefined;
|
||||
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined;
|
||||
if (bindingOwner) return bindingOwner;
|
||||
|
||||
return db.prepare(`
|
||||
@@ -35,7 +39,7 @@ export function findOtherSessionOwner(
|
||||
FROM sessions s
|
||||
JOIN nodes n ON n.id = s.node_id
|
||||
JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE s.session_name = ? AND n.id != ? AND s.status NOT IN ('superseded', 'detached', 'exited')${live}
|
||||
WHERE s.session_name = ? AND ${notExcluded} AND s.status NOT IN ('superseded', 'detached', 'exited')${live}
|
||||
LIMIT 1
|
||||
`).get(tmuxSession, nodeId) as SessionOwnerRow | undefined ?? null;
|
||||
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined ?? null;
|
||||
}
|
||||
@@ -992,18 +992,18 @@ export class SessionTransport {
|
||||
};
|
||||
}
|
||||
|
||||
// #142 — an agent seat whose runtime is not running shows a bare shell, and text typed there runs as
|
||||
// shell commands. A managed launch wrapper can have the same label: only positive native
|
||||
// process proof clears that refusal. A terminal's shell is its runtime; unreadable stays advisory.
|
||||
const bareShell = runtime && runtime !== "terminal"
|
||||
? await this.bareShellForeground(sessionName, runtime, sessionMeta.pane, sessionMeta.resumeToken) : null;
|
||||
if (bareShell) {
|
||||
// #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"
|
||||
? await this.unverifiedShellForeground(sessionName, runtime, sessionMeta.pane, sessionMeta.resumeToken) : null;
|
||||
if (unverifiedShell) {
|
||||
return observe({
|
||||
ok: false,
|
||||
sessionName,
|
||||
sent: false,
|
||||
reason: "target_runtime_not_running",
|
||||
error: `Refused: '${sessionName}' shows a bare ${bareShell} shell, so its ${runtime} runtime is not running. Text sent there would run as shell commands. Relaunch the seat first. No text was sent.`,
|
||||
reason: "target_runtime_unverified",
|
||||
error: `Refused: '${sessionName}' reports ${unverifiedShell} as the foreground command, but OpenRig could not verify its expected ${runtime} agent in the bound pane. The agent may still be running behind a wrapper. No text was sent.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1441,8 +1441,9 @@ export class SessionTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/** The shell name when the pane's foreground is a bare shell; null when it is not, or unknown. */
|
||||
private async bareShellForeground(sessionName: string, runtime: string, pane: string | null, resumeToken: string | null): Promise<string | null> {
|
||||
/** 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> {
|
||||
let paneCommand: string | null;
|
||||
try {
|
||||
paneCommand = await this.tmuxAdapter.getPaneCommand(sessionName);
|
||||
|
||||
@@ -101,6 +101,7 @@ export function createHerdrSocketRpc(
|
||||
return (req) =>
|
||||
new Promise<HerdrResult>((resolve, reject) => {
|
||||
const conn = net.createConnection({ path: socketPath });
|
||||
conn.setEncoding("utf8"); // decode across chunks, so a split multi-byte character stays intact
|
||||
let buf = "";
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
@@ -115,8 +116,8 @@ export function createHerdrSocketRpc(
|
||||
timeoutMs,
|
||||
);
|
||||
conn.on("connect", () => conn.write(`${JSON.stringify(req)}\n`));
|
||||
conn.on("data", (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8");
|
||||
conn.on("data", (chunk: string) => {
|
||||
buf += chunk;
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdirSync, appendFileSync, existsSync, openSync, readSync, closeSync, statSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { join, dirname, relative, isAbsolute, sep } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { getCompatibleOpenRigPath } from "../openrig-compat.js";
|
||||
import { getLastCaptureAt } from "./transcript-rotation.js";
|
||||
@@ -195,9 +195,14 @@ export class TranscriptStore {
|
||||
|
||||
getTranscriptPath(rigName: string, sessionName: string): string {
|
||||
const resolved = join(this.root, rigName, `${sessionName}.log`);
|
||||
// Guard against path traversal from rig/session names containing ".."
|
||||
if (!resolved.startsWith(this.root + "/") && resolved !== this.root) {
|
||||
return join(this.root, "_unsafe", `${sessionName}.log`);
|
||||
// Guard against path traversal from rig/session names containing "..".
|
||||
// Separator-agnostic: `join()` yields backslashes on Windows, so a
|
||||
// `startsWith(root + "/")` check rejected every valid path there and
|
||||
// routed all transcripts to the _unsafe fallback (issue #1).
|
||||
const rel = relative(this.root, resolved);
|
||||
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
const safeSessionName = sessionName.replace(/[\\/]/g, "_");
|
||||
return join(this.root, "_unsafe", `${safeSessionName}.log`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -235,8 +240,11 @@ export class TranscriptStore {
|
||||
if (!this._enabled) return false;
|
||||
try {
|
||||
const dir = join(this.root, rigName);
|
||||
// Guard against path traversal
|
||||
if (!dir.startsWith(this.root + "/") && dir !== this.root) {
|
||||
// Guard against path traversal. Separator-agnostic: `join()` yields
|
||||
// backslashes on Windows, so a `startsWith(root + "/")` check can never
|
||||
// pass there (it rejected every rig dir and transcripts never started).
|
||||
const rel = relative(this.root, dir);
|
||||
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
return false;
|
||||
}
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -1280,6 +1280,9 @@ export type InstantiateOutcome =
|
||||
// teaches the running rig's identity and the supported alternatives.
|
||||
// (Additive variant per orch-lead territory ruling on row r054-s5b-build.)
|
||||
| { ok: false; code: "rig_name_running"; message: string; runningRig: { id: string; name: string; runningSessionCount: number } }
|
||||
// #141: an unarchived same-name rig exists but could not be confirmed stopped, so the YAML import
|
||||
// neither archives it nor creates another same-name generation beside it.
|
||||
| { ok: false; code: "generation_unconfirmed"; message: string }
|
||||
// OPR.0.3.2.CT (conveyor-trust-minimal-fix):
|
||||
// When every launched node reaches a recoverable attention_required
|
||||
// state (e.g., workspace-trust prompt), do NOT tear the rig down.
|
||||
@@ -1293,6 +1296,8 @@ export type InstantiateOutcome =
|
||||
message: string;
|
||||
rigId: string;
|
||||
attentionNodes: AttentionNode[];
|
||||
/** #141: notices that must reach the user even though the import stopped for attention. */
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
export interface AttentionNode {
|
||||
|
||||
@@ -593,16 +593,18 @@ export class WatchdogJobsRepository {
|
||||
* successor re-arms its own. Gen-scoped, NEVER name-scoped (the successor shares the seat name, so a
|
||||
* name-scoped stop would kill the successor's own jobs). A NULL/empty generation never matches
|
||||
* (UNKNOWN ≠ retired — note-2). Returns the count stopped. Auditable (terminal_reason), not a hard
|
||||
* delete — the row stays for forensics. Pre-063 dbs no-op.
|
||||
* delete — the row stays for forensics. Pre-063 dbs no-op. `keepJobIds` are left running: the queue's
|
||||
* current park timers wake the seat that owns a still-blocked row, which the successor inherits.
|
||||
*/
|
||||
dropArmedByRegisteringGeneration(generationUuid: string): number {
|
||||
dropArmedByRegisteringGeneration(generationUuid: string, keepJobIds: readonly string[] = []): number {
|
||||
if (!this.hasGenColumn || !generationUuid) return 0;
|
||||
const keep = keepJobIds.length > 0 ? ` AND job_id NOT IN (${keepJobIds.map(() => "?").join(", ")})` : "";
|
||||
const res = this.db
|
||||
.prepare(
|
||||
`UPDATE watchdog_jobs SET state = 'stopped', terminal_reason = 'registering generation retired (seat handover)'
|
||||
WHERE state = 'active' AND registered_by_generation_uuid = ?`,
|
||||
WHERE state = 'active' AND registered_by_generation_uuid = ?${keep}`,
|
||||
)
|
||||
.run(generationUuid);
|
||||
.run(generationUuid, ...keepJobIds);
|
||||
return res.changes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
WorkflowSpecError,
|
||||
parseWorkflowSpec,
|
||||
@@ -154,7 +155,7 @@ export function loadStarterWorkflowSpecs(opts: StarterSpecLoaderOpts): StarterSp
|
||||
* root.
|
||||
*/
|
||||
export function defaultBuiltinSpecsDir(): string {
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const here = path.dirname(fileURLToPath(new URL(import.meta.url)));
|
||||
// here = .../{src|dist}/domain/workflow
|
||||
// package src/dist root = .../{src|dist}
|
||||
return path.resolve(here, "..", "..", "builtins", "workflow-specs");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createDaemonShutdown, DAEMON_SHUTDOWN_RECEIPT } from "./daemon-shutdown.js";
|
||||
import { readOpenRigEnv, OPENRIG_HOME } from "./openrig-compat.js";
|
||||
import { makeOperatorDeliveryEngine } from "./domain/gateway/operator-delivery-engine.js";
|
||||
@@ -408,9 +409,12 @@ export async function startServer(port?: number) {
|
||||
}
|
||||
|
||||
// Only start the server when this file is executed directly (not imported).
|
||||
// pathToFileURL normalizes argv[1] (backslash paths on Windows) into the same
|
||||
// shape as import.meta.url — a plain `file://${argv[1]}` never matches on win32,
|
||||
// which made the daemon exit silently with code 0.
|
||||
const isDirectRun =
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${process.argv[1]}`;
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (isDirectRun) {
|
||||
startServer();
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,26 +97,28 @@ export function chatRoutes(): Hono {
|
||||
}
|
||||
});
|
||||
|
||||
// Send initial batch
|
||||
const initial = chatRepo.latest(rigId, 20);
|
||||
const sentIds = new Set<string>();
|
||||
for (const msg of initial) {
|
||||
await stream.writeSSE({ id: msg.id, data: JSON.stringify(msg) });
|
||||
sentIds.add(msg.id);
|
||||
}
|
||||
|
||||
// Flush any messages received during initial batch, dedup by ID
|
||||
initialDone = true;
|
||||
for (const pending of pendingMessages) {
|
||||
if (!sentIds.has(pending.id)) {
|
||||
await stream.writeSSE(pending);
|
||||
}
|
||||
}
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
// Send initial batch
|
||||
const initial = chatRepo.latest(rigId, 20);
|
||||
const sentIds = new Set<string>();
|
||||
for (const msg of initial) {
|
||||
await stream.writeSSE({ id: msg.id, data: JSON.stringify(msg) });
|
||||
sentIds.add(msg.id);
|
||||
}
|
||||
|
||||
// Flush any messages received during initial batch, dedup by ID
|
||||
initialDone = true;
|
||||
for (const pending of pendingMessages) {
|
||||
if (!sentIds.has(pending.id)) {
|
||||
await stream.writeSSE(pending);
|
||||
}
|
||||
}
|
||||
|
||||
await aborted;
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ eventsRoute.get("/", (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
// 2. Replay missed events from DB
|
||||
const missed = rigId
|
||||
@@ -66,9 +70,7 @@ eventsRoute.get("/", (c) => {
|
||||
replaying = false;
|
||||
|
||||
// 4. Keep stream alive until client disconnects
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
await aborted;
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export function filesRoutes(): Hono {
|
||||
const rangeHeader = c.req.header("Range");
|
||||
if (rangeHeader) {
|
||||
const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? size - Number(m[2]) : NaN;
|
||||
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? Math.max(0, size - Number(m[2])) : NaN;
|
||||
const end = m && m[1] !== "" && m[2] !== "" ? Number(m[2]) : size - 1;
|
||||
if (!m || Number.isNaN(start) || start < 0 || start >= size || end < start) {
|
||||
return new Response(null, {
|
||||
|
||||
@@ -575,7 +575,7 @@ rigsRoutes.post("/:id/attach-self", async (c) => {
|
||||
const tmuxPane = typeof body["tmuxPane"] === "string" ? body["tmuxPane"].trim() : "";
|
||||
|
||||
const hasNodeTarget = logicalId.length > 0;
|
||||
const hasPodFields = podNamespace.length > 0 || memberName.length > 0 || runtime.length > 0;
|
||||
const hasPodFields = podNamespace.length > 0 || memberName.length > 0;
|
||||
|
||||
if (hasNodeTarget && hasPodFields) {
|
||||
return c.json({ error: "Specify either logicalId or podNamespace + memberName + runtime" }, 400);
|
||||
|
||||
@@ -93,8 +93,10 @@ rigspecImportRoutes.post("/", async (c) => {
|
||||
// S5b final-fix F1: the running-name guard refusal is a conflict on the
|
||||
// direct instantiation route too — never a 500 (map consistency).
|
||||
: outcome.code === "rig_name_running" ? 409
|
||||
// #141: an import refused because a same-name rig could not be confirmed stopped.
|
||||
: outcome.code === "generation_unconfirmed" ? 409
|
||||
: 500;
|
||||
const body = outcome.code === "rig_name_running"
|
||||
const body = outcome.code === "rig_name_running" || outcome.code === "generation_unconfirmed"
|
||||
? { ...outcome, error: outcome.message }
|
||||
: outcome;
|
||||
return c.json(body, status);
|
||||
|
||||
@@ -236,7 +236,7 @@ function fileAssetResponse(absPath: string, contentType: string, rangeHeader?: s
|
||||
|
||||
if (rangeHeader) {
|
||||
const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? size - Number(m[2]) : NaN;
|
||||
const start = m && m[1] !== "" ? Number(m[1]) : m && m[2] !== "" ? Math.max(0, size - Number(m[2])) : NaN;
|
||||
const end = m && m[1] !== "" && m[2] !== "" ? Number(m[2]) : size - 1;
|
||||
if (!m || Number.isNaN(start) || start < 0 || start >= size || end < start) {
|
||||
return new Response(null, {
|
||||
|
||||
@@ -167,22 +167,24 @@ export function streamRoutes(): Hono {
|
||||
}
|
||||
});
|
||||
|
||||
const initial = store.list({ limit: 50 });
|
||||
const sentIds = new Set<string>();
|
||||
for (const item of initial) {
|
||||
await stream.writeSSE({ id: item.streamItemId, data: JSON.stringify(item) });
|
||||
sentIds.add(item.streamItemId);
|
||||
}
|
||||
|
||||
initialDone.value = true;
|
||||
for (const p of pending) {
|
||||
if (!sentIds.has(p.id)) await stream.writeSSE(p);
|
||||
}
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
const initial = store.list({ limit: 50 });
|
||||
const sentIds = new Set<string>();
|
||||
for (const item of initial) {
|
||||
await stream.writeSSE({ id: item.streamItemId, data: JSON.stringify(item) });
|
||||
sentIds.add(item.streamItemId);
|
||||
}
|
||||
|
||||
initialDone.value = true;
|
||||
for (const p of pending) {
|
||||
if (!sentIds.has(p.id)) await stream.writeSSE(p);
|
||||
}
|
||||
|
||||
await aborted;
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
@@ -443,8 +443,9 @@ upRoutes.post("/", async (c) => {
|
||||
const hasConflict = result.stages.some((s) => {
|
||||
if (s.status !== "failed" || s.stage !== "import_rig") return false;
|
||||
const detail = s.detail as { code?: string; message?: string } | undefined;
|
||||
if (detail?.code !== "rig_name_running") return false;
|
||||
topLevelCode ??= "rig_name_running";
|
||||
// #141: an import refused because a same-name rig could not be confirmed stopped is a conflict too.
|
||||
if (detail?.code !== "rig_name_running" && detail?.code !== "generation_unconfirmed") return false;
|
||||
topLevelCode ??= detail.code;
|
||||
conflictError ??= detail.message ?? result.errors[0];
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { configureShadowCapture } from "./domain/shadow-capture.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
|
||||
import { queueRecoveryOwnsWake } from "./domain/queue-wake-ladder.js";
|
||||
@@ -8,7 +9,7 @@ import { HealthDiagnosisService } from "./domain/health-diagnosis.js";
|
||||
import { readHealthArtifact, healthAuthority, healthHumanReadiness } from "./domain/health-context.js";
|
||||
import type { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { ExecFn } from "./adapters/tmux.js";
|
||||
import type { ArgvExecFn, ExecFn } from "./adapters/tmux.js";
|
||||
import type { CmuxTransportFactory } from "./adapters/cmux.js";
|
||||
import { createDb } from "./db/connection.js";
|
||||
import { migrate } from "./db/migrate.js";
|
||||
@@ -25,7 +26,7 @@ import { NodeLauncher } from "./domain/node-launcher.js";
|
||||
import { TmuxOptionDefaultsApplier } from "./domain/tmux-option-defaults.js";
|
||||
import { TmuxAdapter } from "./adapters/tmux.js";
|
||||
import { CmuxAdapter } from "./adapters/cmux.js";
|
||||
import { execCommand } from "./adapters/tmux-exec.js";
|
||||
import { execArgvCommand, execCommand } from "./adapters/tmux-exec.js";
|
||||
import { createCmuxCliTransport } from "./adapters/cmux-transport.js";
|
||||
import { SnapshotRepository } from "./domain/snapshot-repository.js";
|
||||
import { CheckpointStore } from "./domain/checkpoint-store.js";
|
||||
@@ -172,6 +173,8 @@ interface DaemonOptions {
|
||||
* listeners by binding evidence. Absent (tests/legacy) = healthz body unchanged. */
|
||||
bindPlan?: import("./domain/bind-plan.js").BindPlan;
|
||||
tmuxExec?: ExecFn;
|
||||
/** Optional shell-free tmux executor; selected by default on Windows. */
|
||||
argvExec?: ArgvExecFn;
|
||||
cmuxExec?: ExecFn;
|
||||
cmuxFactory?: CmuxTransportFactory;
|
||||
cmuxTimeoutMs?: number;
|
||||
@@ -226,6 +229,10 @@ const KNOWN_PROVIDER_AUTH_ENV = new Set([
|
||||
"OPENROUTER_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
// Issue #194: the bearer token of a Codex Amazon Bedrock provider
|
||||
// (`env_key = "AWS_BEARER_TOKEN_BEDROCK"`). Still forwarded only when the
|
||||
// operator names it in recovery.provider_auth_env_allowlist.
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
]);
|
||||
|
||||
export function collectAllowlistedProviderAuthEnv(
|
||||
@@ -396,7 +403,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
watchdogAutoRegistration.assertLiveSeatCoverage();
|
||||
const watchdogHistoryLogInstance = new WatchdogHistoryLog(db);
|
||||
|
||||
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand);
|
||||
const argvExec = opts?.argvExec
|
||||
?? (opts?.tmuxExec ? undefined : process.platform === "win32" ? execArgvCommand : undefined);
|
||||
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand, undefined, argvExec);
|
||||
const deliveryGuard = new SeatDeliveryGuard(db, target => resolveGuardTarget(db, target));
|
||||
deliveryGuard.recoverActivation();
|
||||
tmuxAdapter.deliveryGuard = deliveryGuard;
|
||||
@@ -920,7 +929,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
sessionRegistry,
|
||||
eventBus,
|
||||
bootstrapOrchestrator,
|
||||
specsDir: nodePath.resolve(nodePath.dirname(new URL(import.meta.url).pathname), "..", "specs"),
|
||||
specsDir: nodePath.resolve(nodePath.dirname(fileURLToPath(new URL(import.meta.url))), "..", "specs"),
|
||||
// V0.3.1 slice 05 — kernel members run against the operator's
|
||||
// workspace, not the daemon installation tree. Without this
|
||||
// cwdOverride, BootstrapOrchestrator refuses with
|
||||
@@ -1790,7 +1799,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// the same daemon endpoint.
|
||||
deps.skillLibraryDiscoveryService = new SkillLibraryDiscoveryService({
|
||||
sharedSkillsDir: nodePath.resolve(
|
||||
nodePath.dirname(new URL(import.meta.url).pathname),
|
||||
nodePath.dirname(fileURLToPath(new URL(import.meta.url))),
|
||||
"..",
|
||||
"specs",
|
||||
"agents",
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// #141: a YAML re-import archives the stopped earlier generation, whose pod members keep the session names the
|
||||
// live replacement now owns. Shrinking the archived pod must neither be blocked by, nor reroute, the replacement's
|
||||
// queue work, and must not kill its session. The owning pod's own refusal and --fallback stay. Fake tmux only.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { PodRepository } from "../src/domain/pod-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
|
||||
import { RigLifecycleService } from "../src/domain/rig-lifecycle-service.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const RIG = "first-project";
|
||||
const SEAT = `crew-lead@${RIG}`;
|
||||
const FALLBACK = "orch-lead@ops-rig";
|
||||
|
||||
describe("#141 shrinking an archived generation's pod vs the live replacement's work", () => {
|
||||
let db: Database.Database;
|
||||
let rigRepo: RigRepository;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let queueRepo: QueueRepository;
|
||||
let killSession: ReturnType<typeof vi.fn>;
|
||||
let lifecycle: RigLifecycleService;
|
||||
let stale: { rigId: string; podId: string };
|
||||
let live: { rigId: string; podId: string; sessionId: string };
|
||||
|
||||
function generation(status: "running" | "exited") {
|
||||
const rig = rigRepo.createRig(RIG);
|
||||
const pod = new PodRepository(db).createPod(rig.id, "crew", "Crew");
|
||||
const node = rigRepo.addNode(rig.id, "crew.lead", { runtime: "claude-code", podId: pod.id });
|
||||
const session = sessionRegistry.registerSession(node.id, SEAT);
|
||||
sessionRegistry.updateStatus(session.id, status);
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: SEAT });
|
||||
return { rigId: rig.id, podId: pod.id, sessionId: session.id };
|
||||
}
|
||||
|
||||
const openWork = () => queueRepo.create({ sourceSession: FALLBACK, destinationSession: SEAT, body: "replacement's work" });
|
||||
const liveStatus = () => (db.prepare("SELECT status FROM sessions WHERE id = ?").get(live.sessionId) as { status: string }).status;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
|
||||
killSession = vi.fn(async () => ({ ok: true as const }));
|
||||
lifecycle = new RigLifecycleService({
|
||||
db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, queueRepo,
|
||||
// hasSession answers only the --fallback destination check; the fallback seat is the one "live" session.
|
||||
tmuxAdapter: { killSession, hasSession: vi.fn(async (name: string) => name === FALLBACK) } as unknown as TmuxAdapter,
|
||||
});
|
||||
// The P1 shape: the stopped generation was archived and the replacement re-created the same seat.
|
||||
stale = generation("exited");
|
||||
rigRepo.archiveRig(stale.rigId);
|
||||
live = generation("running");
|
||||
// A running seat elsewhere that can take explicitly rerouted work.
|
||||
const ops = rigRepo.createRig("ops-rig");
|
||||
const orch = rigRepo.addNode(ops.id, "orch.lead", { runtime: "claude-code" });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(orch.id, FALLBACK).id, "running");
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("shrinking the archived pod is not blocked by the replacement's open work and keeps its session", async () => {
|
||||
const work = await openWork();
|
||||
|
||||
const result = await lifecycle.shrinkPod(stale.rigId, stale.podId);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ok", sessionsKilled: 0, reroutedQitemIds: [] });
|
||||
expect(result.ok && result.nodes[0]).toMatchObject({ status: "removed", sessionKeptFor: `crew.lead@${RIG}` });
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
expect(liveStatus()).toBe("running");
|
||||
});
|
||||
|
||||
it("with --fallback, the replacement's open work does not move", async () => {
|
||||
const work = await openWork();
|
||||
|
||||
const result = await lifecycle.shrinkPod(stale.rigId, stale.podId, { fallbackDestination: FALLBACK });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ok", reroutedQitemIds: [] });
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
expect(liveStatus()).toBe("running");
|
||||
});
|
||||
|
||||
it("control: shrinking the live pod still refuses its own open work without --fallback", async () => {
|
||||
const work = await openWork();
|
||||
|
||||
const result = await lifecycle.shrinkPod(live.rigId, live.podId);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "active_qitems", activeQitemIds: [work.qitemId] });
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("control: shrinking the live pod with --fallback reroutes its own work and kills its session once", async () => {
|
||||
const work = await openWork();
|
||||
|
||||
const result = await lifecycle.shrinkPod(live.rigId, live.podId, { fallbackDestination: FALLBACK });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ok", sessionsKilled: 1, reroutedQitemIds: [work.qitemId] });
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: FALLBACK });
|
||||
expect(killSession).toHaveBeenCalledExactlyOnceWith(SEAT);
|
||||
});
|
||||
});
|
||||
|
||||
// Members of the shrink target can share one session name. Another target member doesn't take that name's work out of
|
||||
// the up-front check: the shrink still refuses without --fallback and reroutes once with it. Only an unarchived owner
|
||||
// OUTSIDE the target does, and it must be found even when a target member's binding would come first.
|
||||
describe("#141 shrink target members sharing a session name", () => {
|
||||
const SHARED = "shared@shared-rig";
|
||||
let db: Database.Database;
|
||||
let rigRepo: RigRepository;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let queueRepo: QueueRepository;
|
||||
let killSession: ReturnType<typeof vi.fn>;
|
||||
let lifecycle: RigLifecycleService;
|
||||
let target: { rigId: string; podId: string };
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
|
||||
killSession = vi.fn(async () => ({ ok: true as const }));
|
||||
lifecycle = new RigLifecycleService({
|
||||
db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, queueRepo,
|
||||
tmuxAdapter: { killSession, hasSession: vi.fn(async (name: string) => name === FALLBACK) } as unknown as TmuxAdapter,
|
||||
});
|
||||
const rig = rigRepo.createRig("shared-rig");
|
||||
const pod = new PodRepository(db).createPod(rig.id, "crew", "Crew");
|
||||
for (const id of ["crew.a", "crew.b"]) {
|
||||
const node = rigRepo.addNode(rig.id, id, { runtime: "claude-code", podId: pod.id });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(node.id, SHARED).id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: SHARED });
|
||||
}
|
||||
target = { rigId: rig.id, podId: pod.id };
|
||||
const ops = rigRepo.createRig("ops-rig");
|
||||
const orch = rigRepo.addNode(ops.id, "orch.lead", { runtime: "claude-code" });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(orch.id, FALLBACK).id, "running");
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
const openWork = () => queueRepo.create({ sourceSession: FALLBACK, destinationSession: SHARED, body: "target's work" });
|
||||
const membersLeft = () => rigRepo.getRig(target.rigId)!.nodes.length;
|
||||
|
||||
it("without --fallback the shrink refuses up front; every member and the work stay", async () => {
|
||||
const work = await openWork();
|
||||
|
||||
const result = await lifecycle.shrinkPod(target.rigId, target.podId);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "active_qitems", activeQitemIds: [work.qitemId] });
|
||||
expect(membersLeft()).toBe(2);
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SHARED, state: "pending" });
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with --fallback the work is rerouted once and the shrink completes", async () => {
|
||||
const work = await openWork();
|
||||
|
||||
const result = await lifecycle.shrinkPod(target.rigId, target.podId, { fallbackDestination: FALLBACK });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ok", reroutedQitemIds: [work.qitemId] });
|
||||
expect(membersLeft()).toBe(0);
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: FALLBACK });
|
||||
expect(killSession).toHaveBeenCalledExactlyOnceWith(SHARED);
|
||||
});
|
||||
|
||||
it.each(["binding", "session row"])("an unarchived owner outside the target (by %s) still keeps its work out of the check", async (via) => {
|
||||
// The target members' bindings exist first, so a lookup that returned one of them before excluding it would miss this owner.
|
||||
const other = rigRepo.addNode(rigRepo.createRig("other-rig").id, "dev.other", { runtime: "claude-code" });
|
||||
if (via === "binding") sessionRegistry.updateBinding(other.id, { tmuxSession: SHARED });
|
||||
else sessionRegistry.updateStatus(sessionRegistry.registerSession(other.id, SHARED).id, "running");
|
||||
const work = await openWork();
|
||||
|
||||
const result = await lifecycle.shrinkPod(target.rigId, target.podId);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ok", sessionsKilled: 0, reroutedQitemIds: [] });
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SHARED, state: "pending" });
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
@@ -52,6 +52,15 @@ describe("chat routes", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("watch disconnect during history releases the subscription", async () => {
|
||||
chatRepo.send(rigId, "alice", "existing history");
|
||||
const before = eventBus.subscriberCount;
|
||||
const res = await app.request(`/api/rigs/${rigId}/chat/watch`);
|
||||
expect(eventBus.subscriberCount).toBe(before + 1);
|
||||
await res.body?.cancel();
|
||||
await vi.waitFor(() => expect(eventBus.subscriberCount).toBe(before), { timeout: 1000 });
|
||||
});
|
||||
|
||||
it("POST /send persists + returns", async () => {
|
||||
const res = await app.request(`/api/rigs/${rigId}/chat/send`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
|
||||
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
|
||||
@@ -336,6 +339,43 @@ describe("Claude Code runtime adapter", () => {
|
||||
expect(store["/project/.claude/agents/reviewer.yaml"]).toBe("name: reviewer");
|
||||
});
|
||||
|
||||
// T9c: file-shaped subagent under PRODUCTION listFiles semantics. The in-memory
|
||||
// mockFs above returns [] for a file path, but production wires listFiles to a
|
||||
// recursive fs.readdirSync walk (startup.ts), which throws ENOTDIR on a file —
|
||||
// an unguarded probe lands the entry in ProjectionResult.failed and it is never
|
||||
// projected. This drives project() through a real-fs-backed listFiles.
|
||||
it("projects file-shaped subagent when listFiles throws ENOTDIR (production readdirSync semantics)", async () => {
|
||||
const tempRoot = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-claude-projection-"));
|
||||
const srcFile = nodePath.join(tempRoot, "agents", "base", "subagents", "reviewer.yaml");
|
||||
fs.mkdirSync(nodePath.dirname(srcFile), { recursive: true });
|
||||
fs.writeFileSync(srcFile, "name: reviewer");
|
||||
const cwd = nodePath.join(tempRoot, "project");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const adapter = new ClaudeCodeAdapter({
|
||||
tmux: mockTmux(),
|
||||
fsOps: {
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
|
||||
copyFile: (src: string, dest: string) => fs.copyFileSync(src, dest),
|
||||
listFiles: (dir: string) => fs.readdirSync(dir),
|
||||
},
|
||||
});
|
||||
const plan: ProjectionPlan = {
|
||||
runtime: "claude-code", cwd,
|
||||
entries: [makeEntry({ category: "subagent", effectiveId: "reviewer", absolutePath: srcFile, resourcePath: "subagents/reviewer.yaml" })],
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
|
||||
};
|
||||
|
||||
const result = await adapter.project(plan, makeBinding(cwd));
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.projected).toEqual(["reviewer"]);
|
||||
expect(fs.readFileSync(nodePath.join(cwd, ".claude", "agents", "reviewer.yaml"), "utf-8")).toBe("name: reviewer");
|
||||
});
|
||||
|
||||
it("applies claude_settings_fragment runtime resources to project-local Claude settings", async () => {
|
||||
const fs = mockFs({
|
||||
"/agents/base/runtime/claude-settings.json": JSON.stringify({
|
||||
|
||||
@@ -221,4 +221,26 @@ describe("OPR.0.4.1.10 B3 — durable disable via removeCodexActivityHooks", ()
|
||||
makeAdapter(fs2).removeCodexActivityHooks();
|
||||
expect(fs2._store[CONFIG]).toBe('[features]\nhooks = true\n'); // untouched
|
||||
});
|
||||
|
||||
// Regression: the strip must excise ONLY the managed block (plus the blank-line separator it was
|
||||
// appended with). A global \n{3,} collapse on the whole document silently mutated user content.
|
||||
const MANAGED_BLOCK =
|
||||
"# BEGIN OPENRIG MANAGED ACTIVITY HOOKS\n" +
|
||||
"[[hooks.Stop]]\n" +
|
||||
`command = 'node "${RELAY}"'\n` +
|
||||
"# END OPENRIG MANAGED ACTIVITY HOOKS\n";
|
||||
|
||||
it("preserves runs of 3+ newlines between unrelated user sections when stripping the block", () => {
|
||||
const userContent = '[features]\nhooks = true\n\n\n\n[projects."/x"]\ntrust_level = "trusted"\n';
|
||||
const fs = mockCodexFs({ [RELAY]: "// relay", [CONFIG]: `${userContent}\n${MANAGED_BLOCK}` });
|
||||
makeAdapter(fs).removeCodexActivityHooks();
|
||||
expect(fs._store[CONFIG]).toBe(userContent); // the 3-blank-line gap survives byte-for-byte
|
||||
});
|
||||
|
||||
it("preserves blank lines inside TOML multiline strings when stripping the block", () => {
|
||||
const userContent = 'instructions = """line1\n\n\nline2"""\n';
|
||||
const fs = mockCodexFs({ [RELAY]: "// relay", [CONFIG]: `${userContent}\n${MANAGED_BLOCK}` });
|
||||
makeAdapter(fs).removeCodexActivityHooks();
|
||||
expect(fs._store[CONFIG]).toBe(userContent); // the parsed value of `instructions` is unchanged
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,9 @@ it("reads foreground identity through the real async executable and parser path"
|
||||
"102 101 102 102 node Sat Jan 1 12:00:00 2000 node /opt/bin/codex -C /project",
|
||||
"103 102 102 102 codex Sat Jan 1 12:00:00 2000 /opt/vendor/bin/codex -C /project",
|
||||
"104 1 104 -1 node Sat Jan 1 12:00:00 2000 node /tmp/other.js --label codex",
|
||||
// Linux task names may contain spaces (prctl PR_SET_NAME / process.title).
|
||||
"106 1 106 -1 tmux: server Sat Jan 1 12:00:00 2000 tmux new-session -d",
|
||||
"107 1 107 -1 node (vitest 1) Sat Jan 1 12:00:00 2000 node (vitest 1)",
|
||||
"broken row",
|
||||
"105 1 105 0",
|
||||
];
|
||||
@@ -34,6 +37,8 @@ it("reads foreground identity through the real async executable and parser path"
|
||||
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project" },
|
||||
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /tmp/other.js --label codex" },
|
||||
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, executableName: "tmux: server", startedAt: "Sat Jan 1 12:00:00 2000", command: "tmux new-session -d" },
|
||||
{ pid: 107, ppid: 1, pgid: 107, tpgid: -1, executableName: "node (vitest 1)", startedAt: "Sat Jan 1 12:00:00 2000", command: "node (vitest 1)" },
|
||||
]);
|
||||
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
expect(await defaultListProcesses()).toEqual([]);
|
||||
|
||||
@@ -1731,4 +1731,40 @@ describe("Codex runtime adapter", () => {
|
||||
);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
// File-shaped subagent under PRODUCTION listFiles semantics: the in-memory
|
||||
// mockFs returns [] for a file path, but production wires listFiles to a
|
||||
// recursive fs.readdirSync walk (startup.ts), which throws ENOTDIR on a file —
|
||||
// an unguarded probe lands the entry in ProjectionResult.failed and it is never
|
||||
// projected. This drives project() through a real-fs-backed listFiles.
|
||||
it("projects file-shaped subagent when listFiles throws ENOTDIR (production readdirSync semantics)", async () => {
|
||||
const tempRoot = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-codex-projection-"));
|
||||
const srcFile = nodePath.join(tempRoot, "agents", "base", "subagents", "reviewer.yaml");
|
||||
fs.mkdirSync(nodePath.dirname(srcFile), { recursive: true });
|
||||
fs.writeFileSync(srcFile, "name: reviewer");
|
||||
const cwd = nodePath.join(tempRoot, "project");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const adapter = new CodexRuntimeAdapter({
|
||||
tmux: mockTmux(),
|
||||
fsOps: {
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
|
||||
listFiles: (dir: string) => fs.readdirSync(dir),
|
||||
},
|
||||
});
|
||||
const plan: ProjectionPlan = {
|
||||
runtime: "codex", cwd,
|
||||
entries: [makeEntry({ category: "subagent", effectiveId: "reviewer", absolutePath: srcFile, resourcePath: "subagents/reviewer.yaml" })],
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
|
||||
};
|
||||
|
||||
const result = await adapter.project(plan, makeBinding(cwd));
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.projected).toEqual(["reviewer"]);
|
||||
expect(fs.readFileSync(nodePath.join(cwd, ".agents", "reviewer.yaml"), "utf-8")).toBe("name: reviewer");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { DiscoveryCoordinator } from "../src/domain/discovery-coordinator.js";
|
||||
import { ClaimService } from "../src/domain/claim-service.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
@@ -186,6 +188,69 @@ describe("DiscoveryCoordinator", () => {
|
||||
expect(second).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves a claim accepted while a rescan is fingerprinting", async () => {
|
||||
const pane = makePane({ activeCommand: "sh" });
|
||||
let releaseFingerprint!: () => void;
|
||||
let fingerprintEntered!: () => void;
|
||||
const entered = new Promise<void>((resolve) => { fingerprintEntered = resolve; });
|
||||
const paused = new Promise<void>((resolve) => { releaseFingerprint = resolve; });
|
||||
const fingerprinter = mockFingerprinter("unknown");
|
||||
(fingerprinter.fingerprint as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
fingerprintEntered();
|
||||
await paused;
|
||||
return { runtimeHint: "unknown", confidence: "low", evidence: { layerUsed: 1 } };
|
||||
});
|
||||
const { coordinator, discoveryRepo, sessionRegistry, eventBus } = buildCoordinator({
|
||||
scanner: mockScanner([pane]), fingerprinter,
|
||||
});
|
||||
const discovered = discoveryRepo.upsertDiscoveredSession({
|
||||
...pane, cwd: "/before", runtimeHint: "unknown", confidence: "low",
|
||||
});
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-1", "r01");
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)").run("n-1", "rig-1", "first");
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)").run("n-2", "rig-1", "second");
|
||||
const claims = new ClaimService({
|
||||
db, discoveryRepo, sessionRegistry, eventBus, rigRepo: new RigRepository(db),
|
||||
});
|
||||
|
||||
const scan = coordinator.scanOnce();
|
||||
await entered;
|
||||
try {
|
||||
expect(await claims.bind({ discoveredId: discovered.id, rigId: "rig-1", logicalId: "first" }))
|
||||
.toMatchObject({ ok: true, nodeId: "n-1" });
|
||||
} finally {
|
||||
releaseFingerprint();
|
||||
}
|
||||
const results = await scan;
|
||||
const duplicate = await claims.bind({ discoveredId: discovered.id, rigId: "rig-1", logicalId: "second" });
|
||||
|
||||
expect(duplicate).toMatchObject({ ok: false, code: "not_active" });
|
||||
expect(discoveryRepo.getDiscoveredSession(discovered.id)).toMatchObject({
|
||||
status: "claimed", claimedNodeId: "n-1", cwd: pane.cwd,
|
||||
});
|
||||
expect(results).toHaveLength(0);
|
||||
expect(db.prepare("SELECT node_id, tmux_session FROM bindings").all()).toEqual([
|
||||
{ node_id: "n-1", tmux_session: pane.tmuxSession },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reactivates vanished discoveries and allows explicitly released claims to be rescanned", () => {
|
||||
const repo = new DiscoveryRepository(db);
|
||||
const data = { tmuxSession: "organic", tmuxPane: "%0", runtimeHint: "unknown" as const, confidence: "low" as const };
|
||||
const first = repo.upsertDiscoveredSession(data);
|
||||
repo.markVanished([first.id]);
|
||||
expect(repo.upsertDiscoveredSession(data)).toMatchObject({
|
||||
id: first.id, firstSeenAt: first.firstSeenAt, status: "active", claimedNodeId: null,
|
||||
});
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-1", "r01");
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)").run("n-1", "rig-1", "dev");
|
||||
repo.markClaimed(first.id, "n-1");
|
||||
repo.releaseClaimByNodeId("n-1");
|
||||
expect(repo.upsertDiscoveredSession({ ...data, cwd: "/updated" })).toMatchObject({
|
||||
id: first.id, status: "active", claimedNodeId: null, cwd: "/updated",
|
||||
});
|
||||
});
|
||||
|
||||
// T7: scanOnce returns active sessions
|
||||
it("scanOnce returns active discovered sessions", async () => {
|
||||
const panes = [makePane({ tmuxPane: "%0" }), makePane({ tmuxPane: "%1" })];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { Hono } from "hono";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
@@ -146,6 +146,16 @@ describe("SSE events route", () => {
|
||||
expect(events.length).toBe(2);
|
||||
});
|
||||
|
||||
it("client disconnect during replay releases the subscription", async () => {
|
||||
const rig = rigRepo.createRig("r01");
|
||||
eventBus.emit({ type: "rig.created", rigId: rig.id });
|
||||
const countBefore = eventBus.subscriberCount;
|
||||
const res = await app.request(`/api/events?rigId=${rig.id}`);
|
||||
expect(eventBus.subscriberCount).toBe(countBefore + 1);
|
||||
await res.body?.cancel();
|
||||
await vi.waitFor(() => expect(eventBus.subscriberCount).toBe(countBefore), { timeout: 1000 });
|
||||
});
|
||||
|
||||
it("client disconnect -> subscriber cleaned up (subscriberCount drops)", async () => {
|
||||
const rig = rigRepo.createRig("r01");
|
||||
const countBefore = eventBus.subscriberCount;
|
||||
|
||||
@@ -63,6 +63,15 @@ describe("GET /api/files/asset — Range + render opt-in", () => {
|
||||
expect(suffix.headers.get("Content-Range")).toBe("bytes 950-999/1000");
|
||||
});
|
||||
|
||||
it.each(["bytes=-1000", "bytes=-2000"])("serves the whole representation for a satisfiable long suffix %s", async (range) => {
|
||||
const res = await app.request(url("media/clip.mp4"), { headers: { Range: range } });
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 0-999/1000");
|
||||
expect(res.headers.get("Content-Length")).toBe("1000");
|
||||
const body = new Uint8Array(await res.arrayBuffer());
|
||||
expect(body).toEqual(Uint8Array.from({ length: 1000 }, (_, i) => i % 251));
|
||||
});
|
||||
|
||||
it("clamps an over-long end and rejects unsatisfiable ranges with 416", async () => {
|
||||
const clamped = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=900-5000" } });
|
||||
expect(clamped.status).toBe(206);
|
||||
@@ -72,6 +81,10 @@ describe("GET /api/files/asset — Range + render opt-in", () => {
|
||||
expect(past.status).toBe(416);
|
||||
expect(past.headers.get("Content-Range")).toBe("bytes */1000");
|
||||
|
||||
const zeroSuffix = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=-0" } });
|
||||
expect(zeroSuffix.status).toBe(416);
|
||||
expect(zeroSuffix.headers.get("Content-Range")).toBe("bytes */1000");
|
||||
|
||||
const garbage = await app.request(url("media/clip.mp4"), { headers: { Range: "bytes=zz" } });
|
||||
expect(garbage.status).toBe(416);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { filesRoutes } from "../src/routes/files.js";
|
||||
@@ -209,6 +210,25 @@ describe("UI Enhancement Pack v0 — /api/files routes", () => {
|
||||
expect((row as { identity_provenance: string | null }).identity_provenance).toBe("claimed:v1");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("preserves executable permissions when replacing a workspace script", async () => {
|
||||
const target = join(tempDir, "workspace", "check.sh");
|
||||
writeFileSync(target, "#!/bin/sh\nprintf old\n");
|
||||
chmodSync(target, 0o755);
|
||||
const expectedMtime = statSync(target).mtime.toISOString();
|
||||
const expectedContentHash = sha256(readFileSync(target));
|
||||
const res = await app.request("/api/files/write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
root: "workspace", path: "check.sh", content: "#!/bin/sh\nprintf updated\n",
|
||||
expectedMtime, expectedContentHash, actor: "test@r",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(statSync(target).mode & 0o777).toBe(0o755);
|
||||
expect(execFileSync(target, { encoding: "utf8" })).toBe("updated");
|
||||
});
|
||||
|
||||
// P21 I5 — files write is a founder-visible surface; resolveActorWithDeferral splits the two paths:
|
||||
// header present (CLI/DaemonClient) => derive + transport:v1 + 409-on-mismatch; header absent
|
||||
// (browser UI) => claimed-era (NULL provenance), never-break (the named deferral, owner=dev50).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// #141: a YAML import refused because a same-name rig could not be confirmed stopped is a 409 conflict with
|
||||
// its actionable message on both import routes, not a 500. Same stubbed seam as the running-name route tests.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { createFullTestDb, createTestApp, mockTmuxAdapter } from "./helpers/test-app.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const POD_YAML = (name: string) => [
|
||||
'version: "0.2"',
|
||||
`name: ${name}`,
|
||||
"pods:",
|
||||
" - id: crew",
|
||||
" label: Crew",
|
||||
" members:",
|
||||
" - id: a",
|
||||
' agent_ref: "builtin:terminal"',
|
||||
' profile: "none"',
|
||||
" runtime: terminal",
|
||||
" cwd: /",
|
||||
" edges: []",
|
||||
"edges: []",
|
||||
].join("\n");
|
||||
|
||||
describe("#141 generation_unconfirmed crosses both import routes as a 409", () => {
|
||||
let db: Database.Database;
|
||||
let setup: ReturnType<typeof createTestApp>;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
// The earlier rig's tmux session is reported present, so it can't be confirmed stopped.
|
||||
const tmux = Object.assign(mockTmuxAdapter(), { probeSession: vi.fn(async () => ({ state: "present" })) }) as unknown as TmuxAdapter;
|
||||
setup = createTestApp(db, {
|
||||
tmux,
|
||||
upRouterFsOps: {
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
readHead: (p: string, n: number) => {
|
||||
const fd = fs.openSync(p, "r");
|
||||
try { const buf = Buffer.alloc(n); return buf.subarray(0, fs.readSync(fd, buf, 0, n, 0)); } finally { fs.closeSync(fd); }
|
||||
},
|
||||
},
|
||||
});
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gen-unconfirmed-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedStoppedRig(name: string) {
|
||||
const rig = setup.rigRepo.createRig(name);
|
||||
const node = setup.rigRepo.addNode(rig.id, "crew.a", { runtime: "claude-code", cwd: "/" });
|
||||
const session = setup.sessionRegistry.registerSession(node.id, `crew-a@${name}`);
|
||||
setup.sessionRegistry.updateStatus(session.id, "running");
|
||||
setup.sessionRegistry.updateBinding(node.id, { tmuxSession: `crew-a@${name}` });
|
||||
setup.sessionRegistry.updateStatus(session.id, "detached");
|
||||
return rig;
|
||||
}
|
||||
|
||||
const rigCount = (name: string) => (db.prepare("SELECT COUNT(*) AS c FROM rigs WHERE name = ?").get(name) as { c: number }).c;
|
||||
|
||||
it("POST /api/rigs/import: 409, top-level code, actionable error naming the earlier rig, nothing created", async () => {
|
||||
const rig = seedStoppedRig("gen-import");
|
||||
const res = await setup.app.request("/api/rigs/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/yaml", "X-Rig-Root": "/tmp" },
|
||||
body: POD_YAML("gen-import"),
|
||||
});
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(body["code"]).toBe("generation_unconfirmed");
|
||||
expect(String(body["error"])).toContain(rig.id);
|
||||
expect(String(body["error"])).toMatch(/nothing was created/i);
|
||||
expect(rigCount("gen-import")).toBe(1);
|
||||
});
|
||||
|
||||
it("POST /api/up: 409, top-level code, actionable error naming the earlier rig, nothing created", async () => {
|
||||
const rig = seedStoppedRig("gen-up");
|
||||
const specPath = path.join(tmpDir, "rig.yaml");
|
||||
fs.writeFileSync(specPath, POD_YAML("gen-up"));
|
||||
const res = await setup.app.request("/api/up", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceRef: specPath }),
|
||||
});
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(body["code"]).toBe("generation_unconfirmed");
|
||||
expect(String(body["error"])).toContain(rig.id);
|
||||
expect(rigCount("gen-up")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
// A seat swap stops the watchdog jobs the retiring occupant registered, so a stale wake doesn't reach the successor.
|
||||
// A blocked row's current park timer is different: it wakes the seat that still owns the row, and the successor
|
||||
// inherits that row. It survives the swap; every other job the retiring generation registered still stops.
|
||||
// Real queue, watchdog and session repositories on a full test DB, wired as startup.ts wires them.
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
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 { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
|
||||
import { DefaultOccupantInvalidator } from "../src/domain/occupant-invalidator.js";
|
||||
import { diagnoseSeatParked } from "../src/domain/parked-query.js";
|
||||
import { WatchdogPolicyEngine } from "../src/domain/watchdog-policy-engine.js";
|
||||
import { WatchdogHistoryLog } from "../src/domain/watchdog-history-log.js";
|
||||
import { watchdogHistorySchema } from "../src/db/migrations/032_watchdog_history.js";
|
||||
|
||||
const SEAT = "dev-impl@seat-rig";
|
||||
const OTHER = "dev-qa@seat-rig";
|
||||
|
||||
describe("#handover-wake: a blocked row's park timer across an occupant swap", () => {
|
||||
let db: Database.Database;
|
||||
let queue: QueueRepository;
|
||||
let jobs: WatchdogJobsRepository;
|
||||
let invalidator: DefaultOccupantInvalidator;
|
||||
let gen: (session: string) => string | null;
|
||||
let seatNodeId: string;
|
||||
let otherNodeId: string;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let bus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
db.exec(watchdogHistorySchema.sql); // the engine audits each evaluation; the full test DB does not include this table
|
||||
const rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
const rig = rigRepo.createRig("seat-rig");
|
||||
for (const [logicalId, session] of [["dev.impl", SEAT], ["dev.qa", OTHER]] as const) {
|
||||
const node = rigRepo.addNode(rig.id, logicalId, { runtime: "codex" });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(node.id, session).id, "running");
|
||||
if (session === SEAT) seatNodeId = node.id; else otherNodeId = node.id;
|
||||
}
|
||||
gen = (session) => sessionRegistry.currentOccupantGenerationForSession(session);
|
||||
bus = new EventBus(db);
|
||||
queue = new QueueRepository(db, bus, { validateRig: () => true, resolveOccupantGeneration: gen });
|
||||
jobs = new WatchdogJobsRepository(db, undefined, gen);
|
||||
queue.attachWatchdogJobsRepository(jobs);
|
||||
invalidator = new DefaultOccupantInvalidator({
|
||||
enforcer: { invalidateOccupant() {} }, contextUsage: { invalidateOccupantSidecar() {} }, watchdog: jobs, queue,
|
||||
});
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
async function park(opts: { destination?: string; actor?: string; repeating?: boolean; watchdogId?: string } = {}) {
|
||||
const row = await queue.create({ sourceSession: "orch-lead@seat-rig", destinationSession: opts.destination ?? SEAT, body: "work" } as never);
|
||||
queue.update({
|
||||
qitemId: row.qitemId, actorSession: opts.actor ?? SEAT, state: "blocked", blockedOn: "external:ci",
|
||||
transitionNote: "continuation: check CI",
|
||||
...(opts.watchdogId ? { wakeWatchdogId: opts.watchdogId } : { wakeAfterSeconds: 1800, ...(opts.repeating ? { wakeMaxSeconds: 7200 } : {}) }),
|
||||
} as never);
|
||||
return { qitemId: row.qitemId, wakeRef: (queue.getParkWakeStatus(row.qitemId) as { ref: string }).ref };
|
||||
}
|
||||
const swap = () => invalidator.invalidateRetiringOccupant({ retiringSessionName: SEAT, successorSessionName: SEAT, retiringGeneration: gen(SEAT)! });
|
||||
const jobState = (jobId: string) => (db.prepare("SELECT state FROM watchdog_jobs WHERE job_id = ?").get(jobId) as { state: string }).state;
|
||||
const activeJobsFor = (session: string) => (db.prepare("SELECT COUNT(*) AS n FROM watchdog_jobs WHERE state = 'active' AND target_session = ?").get(session) as { n: number }).n;
|
||||
const wakeLive = (qitemId: string) => (queue.getParkWakeStatus(qitemId) as { live: boolean }).live;
|
||||
const parkedWhenIdle = (session = SEAT) => diagnoseSeatParked({
|
||||
getSeatState: () => ({ activity: "idle-at-prompt", needsInput: { count: 0, reason: null }, decidedBy: "test" }) as never,
|
||||
listOpenObligations: (destinationSession, limit) => ({
|
||||
rows: queue.list({ destinationSession, state: ["pending", "in-progress", "blocked"], limit })
|
||||
.map((r) => ({ qitemId: r.qitemId, state: r.state as "pending" | "in-progress" | "blocked", summary: r.summary ?? null })),
|
||||
limit,
|
||||
}),
|
||||
getParkWake: (qitemId) => queue.getParkWakeStatus(qitemId),
|
||||
}, { seatNodeId: session === SEAT ? seatNodeId : otherNodeId, sessionName: session });
|
||||
|
||||
it.each([false, true])("the still-owned blocked row keeps its one live timer (repeating=%s)", async (repeating) => {
|
||||
const { qitemId, wakeRef } = await park({ repeating });
|
||||
|
||||
swap();
|
||||
|
||||
expect(jobState(wakeRef)).toBe("active");
|
||||
expect(wakeLive(qitemId)).toBe(true);
|
||||
expect(activeJobsFor(SEAT)).toBe(1);
|
||||
expect(parkedWhenIdle().obligations.unhealthyHeldCount).toBe(0);
|
||||
});
|
||||
|
||||
it("a successor re-park still supersedes the kept timer: one live timer, not two", async () => {
|
||||
const { qitemId, wakeRef } = await park();
|
||||
swap();
|
||||
|
||||
queue.update({ qitemId, actorSession: SEAT, state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: re-parked", wakeAfterSeconds: 600 } as never);
|
||||
|
||||
expect(jobState(wakeRef)).not.toBe("active");
|
||||
expect(activeJobsFor(SEAT)).toBe(1);
|
||||
});
|
||||
|
||||
it("another seat's blocked row parked by the retiring occupant keeps its live timer", async () => {
|
||||
const { qitemId, wakeRef } = await park({ destination: OTHER, actor: SEAT });
|
||||
|
||||
swap();
|
||||
|
||||
expect(jobState(wakeRef)).toBe("active");
|
||||
expect(wakeLive(qitemId)).toBe(true);
|
||||
});
|
||||
|
||||
it("control: an occupant-only job the retiring generation registered still stops", () => {
|
||||
const own = jobs.register({
|
||||
policy: "periodic-reminder", targetSession: SEAT, intervalSeconds: 600, registeredBySession: SEAT,
|
||||
specYaml: ["policy: periodic-reminder", "target:", ` session: ${JSON.stringify(SEAT)}`, 'message: "own reminder"', ""].join("\n"),
|
||||
});
|
||||
|
||||
swap();
|
||||
|
||||
expect(jobState(own.jobId)).toBe("stopped");
|
||||
});
|
||||
|
||||
it("control: an operator watchdog attached to the blocked row still stops (custom job, not queue-generated)", async () => {
|
||||
const attached = jobs.register({
|
||||
policy: "periodic-reminder", targetSession: SEAT, intervalSeconds: 600, registeredBySession: SEAT,
|
||||
specYaml: ["policy: periodic-reminder", "target:", ` session: ${JSON.stringify(SEAT)}`, 'message: "operator watchdog"', ""].join("\n"),
|
||||
});
|
||||
await park({ watchdogId: attached.jobId });
|
||||
|
||||
swap();
|
||||
|
||||
expect(jobState(attached.jobId)).toBe("stopped");
|
||||
});
|
||||
|
||||
it("control: a row rerouted to another seat does not keep the old seat's timer", async () => {
|
||||
const { qitemId, wakeRef } = await park();
|
||||
queue.routeToFallback(qitemId, OTHER, "test transfer");
|
||||
|
||||
swap();
|
||||
|
||||
expect(jobState(wakeRef)).not.toBe("active");
|
||||
});
|
||||
|
||||
// review-r2 (PR #242): a rerouted row's park timer must not keep waking the OLD owner. The scheduler evaluates
|
||||
// active jobs; this evaluates every active job, due or not, through the real engine and pre-delivery check.
|
||||
async function fireAllActive() {
|
||||
const deliveries: Array<{ targetSession: string; message: string }> = [];
|
||||
const engine = new WatchdogPolicyEngine({
|
||||
jobsRepo: jobs, historyLog: new WatchdogHistoryLog(db), eventBus: bus,
|
||||
deliver: async (request) => { deliveries.push(request); return { status: "ok" }; },
|
||||
resolvePreDeliveryTerminalReason: ({ jobId }: { jobId: string }) => queue.resolveWatchdogPreDeliveryTerminalReason(jobId),
|
||||
resolveQueueWait: (input: { jobId: string }) => queue.evaluateWaitReminder(input),
|
||||
onWakeAttempt: ({ jobId, deliveryStatus }) => queue.recordWatchdogWakeAttempt(jobId, deliveryStatus),
|
||||
});
|
||||
for (const job of jobs.listActive()) await engine.evaluate(job);
|
||||
return deliveries;
|
||||
}
|
||||
|
||||
it("park, reroute, fire: nothing reaches the old owner (no handover)", async () => {
|
||||
const { qitemId, wakeRef } = await park();
|
||||
|
||||
queue.routeToFallback(qitemId, OTHER, "test reroute");
|
||||
|
||||
expect(jobState(wakeRef)).not.toBe("active");
|
||||
expect(wakeLive(qitemId)).toBe(false);
|
||||
expect(parkedWhenIdle(OTHER).obligations.unhealthyHeldCount).toBe(1);
|
||||
expect((await fireAllActive()).filter((d) => d.targetSession === SEAT)).toEqual([]);
|
||||
});
|
||||
|
||||
it("park, swap (timer kept), reroute, swap, fire: nothing reaches the old owner", async () => {
|
||||
const { qitemId, wakeRef } = await park();
|
||||
swap();
|
||||
expect(jobState(wakeRef)).toBe("active");
|
||||
queue.routeToFallback(qitemId, OTHER, "test reroute");
|
||||
const firstGeneration = gen(SEAT);
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(seatNodeId, SEAT).id, "running");
|
||||
expect(gen(SEAT)).not.toBe(firstGeneration);
|
||||
|
||||
swap();
|
||||
|
||||
expect(jobState(wakeRef)).not.toBe("active");
|
||||
expect(wakeLive(qitemId)).toBe(false);
|
||||
expect(parkedWhenIdle(OTHER).obligations.unhealthyHeldCount).toBe(1);
|
||||
expect((await fireAllActive()).filter((d) => d.targetSession === SEAT)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])("a repeating wait survives the reroute and wakes only the new owner (swaps=%s)", async (withSwaps) => {
|
||||
const { qitemId, wakeRef } = await park({ repeating: true });
|
||||
if (withSwaps) swap();
|
||||
queue.routeToFallback(qitemId, OTHER, "test reroute");
|
||||
if (withSwaps) {
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(seatNodeId, SEAT).id, "running");
|
||||
swap();
|
||||
}
|
||||
|
||||
expect(jobState(wakeRef)).toBe("active");
|
||||
expect(wakeLive(qitemId)).toBe(true);
|
||||
const deliveries = await fireAllActive();
|
||||
expect(deliveries.map((d) => d.targetSession)).toEqual([OTHER]);
|
||||
});
|
||||
|
||||
it("control: a stale timer that is no longer the row's current wake still stops", async () => {
|
||||
const { qitemId, wakeRef: first } = await park();
|
||||
queue.update({ qitemId, actorSession: SEAT, state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: re-parked", wakeAfterSeconds: 600 } as never);
|
||||
// Legacy residue: a superseded timer left active by a path that did not retire it.
|
||||
db.prepare("UPDATE watchdog_jobs SET state = 'active', terminal_reason = NULL WHERE job_id = ?").run(first);
|
||||
|
||||
swap();
|
||||
|
||||
expect(jobState(first)).toBe("stopped");
|
||||
expect(wakeLive(qitemId)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// The real createHerdrSocketRpc over a private unix socket. A UTF-8 reply split inside a
|
||||
// multi-byte character must still parse to the exact original text.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHerdrSocketRpc } from "../src/domain/terminal/herdr-transport.js";
|
||||
|
||||
const TEXT = "café 漢 😀";
|
||||
const servers: net.Server[] = [];
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) await new Promise((resolve) => server.close(resolve));
|
||||
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Serve one request, writing each reply part as a separate chunk. */
|
||||
async function serve(reply: (req: { id: string }) => Buffer[]): Promise<string> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-"));
|
||||
dirs.push(dir);
|
||||
const socketPath = path.join(dir, "h.sock");
|
||||
const server = net.createServer((conn) => {
|
||||
let buf = "";
|
||||
conn.on("data", async (data) => {
|
||||
buf += data.toString("utf8");
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl < 0) return;
|
||||
for (const part of reply(JSON.parse(buf.slice(0, nl)))) {
|
||||
conn.write(part);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(socketPath, resolve));
|
||||
return socketPath;
|
||||
}
|
||||
|
||||
const line = (id: string) => Buffer.from(`${JSON.stringify({ id, result: { type: "pane_output", text: TEXT } })}\n`);
|
||||
const splitInside = (bytes: Buffer, char: string, into: number) => {
|
||||
const at = bytes.indexOf(Buffer.from(char)) + into;
|
||||
return [bytes.subarray(0, at), bytes.subarray(at)];
|
||||
};
|
||||
|
||||
describe("herdr socket RPC keeps UTF-8 across chunk boundaries", () => {
|
||||
it.each([
|
||||
["é", 2, 1],
|
||||
["漢", 3, 1],
|
||||
["漢", 3, 2],
|
||||
["😀", 4, 1],
|
||||
["😀", 4, 2],
|
||||
["😀", 4, 3],
|
||||
] as const)("a chunk boundary inside %s (%i bytes) at byte %i", async (char, _bytes, into) => {
|
||||
const socketPath = await serve((req) => splitInside(line(req.id), char, into));
|
||||
const rpc = createHerdrSocketRpc(socketPath, 2000);
|
||||
|
||||
await expect(rpc({ id: "openrig-1", method: "pane.read", params: {} }))
|
||||
.resolves.toEqual({ type: "pane_output", text: TEXT });
|
||||
});
|
||||
|
||||
it("still skips another id's line and matches this request's split reply", async () => {
|
||||
const socketPath = await serve((req) => {
|
||||
const other = Buffer.from(`${JSON.stringify({ id: "other", result: { type: "x", text: "no" } })}\n`);
|
||||
const [head, tail] = splitInside(line(req.id), "😀", 2);
|
||||
return [Buffer.concat([other, head!]), tail!];
|
||||
});
|
||||
const rpc = createHerdrSocketRpc(socketPath, 2000);
|
||||
|
||||
await expect(rpc({ id: "openrig-1", method: "pane.read", params: {} }))
|
||||
.resolves.toEqual({ type: "pane_output", text: TEXT });
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
selectVariant,
|
||||
kernelAlreadyManaged,
|
||||
authBlockMessage,
|
||||
probeCodexReadiness,
|
||||
selectCodexProviderAuth,
|
||||
type KernelBootDeps,
|
||||
type RuntimeAuthStatus,
|
||||
} from "../src/domain/kernel-boot.js";
|
||||
import type { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import type { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
|
||||
@@ -244,3 +247,86 @@ describe("bootKernelIfNeeded — fire-and-forget bootstrap", () => {
|
||||
tracker.stop();
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #194 — a Codex provider that does not use an OpenAI login.
|
||||
const BEDROCK_CONFIG = [
|
||||
'model_provider = "bedrock-runtime-us"',
|
||||
"",
|
||||
"[model_providers.bedrock-runtime-us]",
|
||||
'name = "Amazon Bedrock Runtime"',
|
||||
'env_key = "AWS_BEARER_TOKEN_BEDROCK"',
|
||||
'wire_api = "responses"',
|
||||
"requires_openai_auth = false",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
function codexProbe(opts: { config: string | null; env?: Record<string, string>; loggedIn?: boolean; installed?: boolean }) {
|
||||
const ran: string[] = [];
|
||||
const run = async (cmd: string): Promise<RuntimeAuthStatus> => {
|
||||
ran.push(cmd);
|
||||
if (opts.installed === false) return "unavailable";
|
||||
if (cmd === "codex login status") return opts.loggedIn ? "ok" : "unavailable";
|
||||
return "ok";
|
||||
};
|
||||
return { ran, deps: { run, readConfig: () => opts.config, env: opts.env ?? {} } };
|
||||
}
|
||||
|
||||
describe("selectCodexProviderAuth — provider-aware Codex readiness (#194)", () => {
|
||||
it("uses the credential variable only for an explicit non-OpenAI provider with env_key", () => {
|
||||
expect(selectCodexProviderAuth(BEDROCK_CONFIG)).toEqual({
|
||||
kind: "env-key", providerId: "bedrock-runtime-us", envKey: "AWS_BEARER_TOKEN_BEDROCK",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the OpenAI login check for every unresolved or OpenAI-auth case", () => {
|
||||
expect(selectCodexProviderAuth(null)).toEqual({ kind: "openai-login" });
|
||||
expect(selectCodexProviderAuth('model_provider = "openai"\n')).toEqual({ kind: "openai-login" });
|
||||
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace("requires_openai_auth = false", "requires_openai_auth = true")).kind).toBe("openai-login");
|
||||
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace("requires_openai_auth = false", "")).kind).toBe("openai-login");
|
||||
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace('env_key = "AWS_BEARER_TOKEN_BEDROCK"', "")).kind).toBe("openai-login");
|
||||
expect(selectCodexProviderAuth(BEDROCK_CONFIG.replace('model_provider = "bedrock-runtime-us"', 'model_provider = "missing"')).kind).toBe("openai-login");
|
||||
expect(selectCodexProviderAuth('model_provider = "__proto__"\n')).toEqual({ kind: "openai-login" });
|
||||
expect(selectCodexProviderAuth("model_provider = [broken")).toEqual({ kind: "openai-login", unresolved: "config.toml could not be parsed" });
|
||||
expect(selectCodexProviderAuth(`profile = "work"\n${BEDROCK_CONFIG}`).kind).toBe("openai-login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeCodexReadiness — the kernel's Codex probe (#194)", () => {
|
||||
it("reports ok for the Bedrock provider with its token set and no OpenAI login, without running codex login status", async () => {
|
||||
const probe = codexProbe({ config: BEDROCK_CONFIG, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, loggedIn: false });
|
||||
expect(await probeCodexReadiness(probe.deps)).toBe("ok");
|
||||
expect(probe.ran).toEqual(["codex --version"]);
|
||||
});
|
||||
|
||||
it("reports unavailable when the provider's variable is missing or blank", async () => {
|
||||
for (const env of [{}, { AWS_BEARER_TOKEN_BEDROCK: " " }]) {
|
||||
const probe = codexProbe({ config: BEDROCK_CONFIG, env, loggedIn: true });
|
||||
expect(await probeCodexReadiness(probe.deps)).toBe("unavailable");
|
||||
expect(probe.ran).not.toContain("codex login status");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unavailable when the Codex executable is missing even with the token set", async () => {
|
||||
const probe = codexProbe({ config: BEDROCK_CONFIG, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, installed: false });
|
||||
expect(await probeCodexReadiness(probe.deps)).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("keeps the OpenAI login result for default, OpenAI-auth and malformed configurations", async () => {
|
||||
for (const config of [null, 'model_provider = "openai"\n', "model_provider = [broken"]) {
|
||||
for (const loggedIn of [true, false]) {
|
||||
const probe = codexProbe({ config, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, loggedIn });
|
||||
expect(await probeCodexReadiness(probe.deps)).toBe(loggedIn ? "ok" : "unavailable");
|
||||
expect(probe.ran).toEqual(["codex login status"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("lets the kernel select the Codex variant for a Bedrock-only machine", async () => {
|
||||
const probe = codexProbe({ config: BEDROCK_CONFIG, env: { AWS_BEARER_TOKEN_BEDROCK: "synthetic-token" }, loggedIn: false });
|
||||
const tracker = await bootKernelIfNeeded(makeBaseDeps({
|
||||
probeRuntimes: async () => ({ claudeCode: "unavailable", codex: await probeCodexReadiness(probe.deps) }),
|
||||
}, tmpSpecsDir));
|
||||
expect(tracker.getStatus().variant).toBe("rig-codex-only.yaml");
|
||||
tracker.stop();
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,17 @@ describe("kernel agents — profile.uses references resolve against shared pool"
|
||||
}
|
||||
});
|
||||
|
||||
function usedRuntimeResources(yamlPath: string): { profile: string; runtimeResources: string[] }[] {
|
||||
const doc = parseYaml(readFileSync(yamlPath, "utf-8")) as {
|
||||
profiles?: Record<string, { uses?: { runtime_resources?: string[] } }>;
|
||||
};
|
||||
const out: { profile: string; runtimeResources: string[] }[] = [];
|
||||
for (const [profile, p] of Object.entries(doc.profiles ?? {})) {
|
||||
out.push({ profile, runtimeResources: p?.uses?.runtime_resources ?? [] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const agentPath of KERNEL_AGENT_PATHS) {
|
||||
const label = agentPath
|
||||
.replace(KERNEL_AGENTS_DIR + "/", "")
|
||||
@@ -136,6 +147,26 @@ describe("kernel agents — profile.uses references resolve against shared pool"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// #160 — a kernel seat running the Claude runtime must report activity out of
|
||||
// the box. Every profile that opts into the shared Claude defaults must also
|
||||
// opt into the shared activity hooks; without the relay the daemon's freshness
|
||||
// gate records every hookless Claude kernel seat as `unknown`.
|
||||
for (const agentPath of KERNEL_AGENT_PATHS) {
|
||||
const label = agentPath
|
||||
.replace(KERNEL_AGENTS_DIR + "/", "")
|
||||
.replace("/agent.yaml", "")
|
||||
.replace("/", ".");
|
||||
it(`${label}: claude-default profiles include shared:claude-activity-hooks`, () => {
|
||||
expect(poolIds(SHARED_AGENT_YAML, "runtime_resources").has("claude-activity-hooks")).toBe(true);
|
||||
const claudeProfiles = usedRuntimeResources(agentPath)
|
||||
.filter(({ runtimeResources }) => runtimeResources.includes("shared:claude-default-settings"));
|
||||
expect(claudeProfiles.length, `${label} declares no claude-default profile`).toBeGreaterThan(0);
|
||||
for (const { profile, runtimeResources } of claudeProfiles) {
|
||||
expect(runtimeResources, `${label} profile=${profile}`).toContain("shared:claude-activity-hooks");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("kernel agents — nested AgentSpec validation", () => {
|
||||
|
||||
@@ -85,6 +85,29 @@ describe("parseMarkdownSections — code-fence-protected header tree", () => {
|
||||
"reference/commands-flags",
|
||||
]);
|
||||
});
|
||||
it.each(["```", "~~~"])("keeps a %s fence open when a marker line has trailing text", (marker) => {
|
||||
const text = [
|
||||
"## Actual", `${marker}text`, `${marker} not a closing fence`,
|
||||
"## Example heading", marker, "## Next", "real prose",
|
||||
].join("\n");
|
||||
expect(parseMarkdownSections(text).map((section) => section.headerPath)).toEqual([["actual"], ["next"]]);
|
||||
expect(resolveAddress(text, ["actual"]).text).toContain("## Example heading");
|
||||
expect(() => resolveAddress(text, ["example-heading"])).toThrow(AddressResolutionError);
|
||||
expect(resolveAddress(text, ["next"]).text).toContain("real prose");
|
||||
expect(validateMarkdownAddressability(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["```", "~~~"])("preserves %s info strings, marker matching, length and CRLF whitespace closers", (marker) => {
|
||||
const other = marker === "```" ? "~~~" : "```";
|
||||
const text = [
|
||||
"## Actual", `${marker}${marker[0]}language`, marker, "## Too short", other,
|
||||
"## Different marker", `${marker}${marker[0]}${marker[0]} \t`, "## Next", "prose",
|
||||
].join("\r\n");
|
||||
expect(parseMarkdownSections(text).map((section) => section.headerPath)).toEqual([["actual"], ["next"]]);
|
||||
expect(resolveAddress(text, ["actual"]).text).toContain("## Different marker");
|
||||
expect(validateMarkdownAddressability(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("spans terminate at the next SAME-OR-HIGHER header (Q1), own text at ANY header", () => {
|
||||
const sections = parseMarkdownSections(CORPUS);
|
||||
const gettingStarted = sections.find((s) => s.headerPath.join("/") === "getting-started")!;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Native process observation must not depend on the daemon's inherited locale.
|
||||
// `ps` formats lstart through LC_TIME, so a non-English locale used to make every
|
||||
// row fail the parser and both listers returned []. This drives the REAL listers
|
||||
// (real execFile, real child environment) against a hermetic `ps` that formats
|
||||
// its date the way the effective locale would: LC_ALL, then LC_TIME, then LANG.
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { listNativeProcesses } from "../src/domain/native-process-lineage.js";
|
||||
import { defaultListProcessesStrict } from "../src/domain/resume-metadata-refresher.js";
|
||||
|
||||
const ENGLISH_START = "Wed Sep 30 21:44:48 2026";
|
||||
const MARKER = "synthetic-unrelated-marker";
|
||||
|
||||
let scratch: string | undefined;
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
|
||||
scratch = undefined;
|
||||
});
|
||||
|
||||
function installLocaleAwarePs(): string {
|
||||
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "native-ps-locale-"));
|
||||
const envLog = path.join(scratch, "child-env.txt");
|
||||
fs.writeFileSync(path.join(scratch, "ps"), [
|
||||
"#!/bin/sh",
|
||||
`/usr/bin/env > '${envLog}'`,
|
||||
'effective="${LC_ALL:-${LC_TIME:-$LANG}}"',
|
||||
'case "$effective" in',
|
||||
` ""|C|C.*|POSIX|en_*) started="${ENGLISH_START}" ;;`,
|
||||
' *) started="Mi 30 Sep 21:44:48 2026" ;;',
|
||||
"esac",
|
||||
'case "$2" in',
|
||||
' *ucomm*) printf \'%s\\n\' "PID PPID PGID TPGID UCOMM STARTED COMMAND" "101 1 101 101 codex $started codex resume tok-1 $OPENRIG_PS_MARKER" "102 1 102 102 zsh $started -zsh" ;;',
|
||||
' *) printf \'%s\\n\' "PID PPID STARTED COMMAND" "101 1 $started codex resume tok-1 $OPENRIG_PS_MARKER" "102 1 $started -zsh" ;;',
|
||||
"esac",
|
||||
"",
|
||||
].join("\n"), { mode: 0o755 });
|
||||
vi.stubEnv("PATH", scratch);
|
||||
vi.stubEnv("OPENRIG_PS_MARKER", MARKER);
|
||||
return envLog;
|
||||
}
|
||||
|
||||
const INHERITED_LOCALES: Array<{ name: string; LC_ALL?: string; LC_TIME?: string; LANG?: string }> = [
|
||||
{ name: "LANG=de_DE.UTF-8", LANG: "de_DE.UTF-8" },
|
||||
{ name: "LC_TIME=fr_FR.UTF-8 over LANG=en_US.UTF-8", LC_TIME: "fr_FR.UTF-8", LANG: "en_US.UTF-8" },
|
||||
{ name: "LC_ALL=ja_JP.UTF-8", LC_ALL: "ja_JP.UTF-8" },
|
||||
];
|
||||
|
||||
const LISTERS = [
|
||||
["listNativeProcesses", listNativeProcesses],
|
||||
["defaultListProcessesStrict", defaultListProcessesStrict],
|
||||
] as const;
|
||||
|
||||
describe.each(LISTERS)("%s under an inherited non-English locale", (_name, list) => {
|
||||
for (const locale of INHERITED_LOCALES) {
|
||||
it(`keeps every row under ${locale.name}, with a child-only C locale`, async () => {
|
||||
const envLog = installLocaleAwarePs();
|
||||
vi.stubEnv("LC_ALL", locale.LC_ALL);
|
||||
vi.stubEnv("LC_TIME", locale.LC_TIME);
|
||||
vi.stubEnv("LANG", locale.LANG);
|
||||
|
||||
const rows = await list();
|
||||
|
||||
expect(rows.map((row) => row.pid)).toEqual([101, 102]);
|
||||
expect(rows.every((row) => row.startedAt === ENGLISH_START)).toBe(true);
|
||||
expect(rows[0]!.command).toBe(`codex resume tok-1 ${MARKER}`);
|
||||
|
||||
const childEnv = fs.readFileSync(envLog, "utf8").split("\n");
|
||||
expect(childEnv).toContain("LC_ALL=C");
|
||||
expect(childEnv).toContain(`OPENRIG_PS_MARKER=${MARKER}`);
|
||||
expect(childEnv).toContain(`PATH=${scratch}`);
|
||||
|
||||
// The override is child-only: the daemon's own environment is untouched.
|
||||
expect(process.env.LC_ALL).toBe(locale.LC_ALL);
|
||||
expect(process.env.LC_TIME).toBe(locale.LC_TIME);
|
||||
expect(process.env.LANG).toBe(locale.LANG);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -48,7 +48,8 @@ describe("GHOST-STAGE (e) DefaultOccupantInvalidator", () => {
|
||||
watchdog: { dropArmedByRegisteringGeneration },
|
||||
log: (m) => logs.push(m),
|
||||
}).invalidateRetiringOccupant({ retiringSessionName: "seat@rig", successorSessionName: "seat@rig", retiringGeneration: "gen-retired" });
|
||||
expect(dropArmedByRegisteringGeneration).toHaveBeenCalledWith("gen-retired");
|
||||
// No queue dep wired, so no park timers are kept.
|
||||
expect(dropArmedByRegisteringGeneration).toHaveBeenCalledWith("gen-retired", []);
|
||||
expect(logs.some((l) => /stopped 2 armed watchdog/.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
// composition never silently truncates (mini-req 9, D2: budgets flag, never govern).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { makeProfileReadFile } from "../src/domain/context-packs/profile-source-resolver.js";
|
||||
import type { ContextPackAtom } from "../src/domain/context-packs/context-pack-types.js";
|
||||
import { composeProfile, ProfileComposeError } from "../src/domain/context-packs/profile-composer.js";
|
||||
|
||||
@@ -109,6 +113,24 @@ describe("composeProfile — runtime split (mini-req 3)", () => {
|
||||
});
|
||||
|
||||
describe("composeProfile — fail-loud resolution (the Atom-1 contract carried through)", () => {
|
||||
it("composes a real section after a fenced example containing a marker with trailing text", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "profile-fence-"));
|
||||
try {
|
||||
writeFileSync(join(root, "walk.md"), [
|
||||
"## Example", "```markdown", "```language", "## Example-only heading", "```",
|
||||
"## Instructions", "continue with the real instructions",
|
||||
].join("\n"));
|
||||
const readFile = makeProfileReadFile({ packDir: root, roots: {} });
|
||||
const composed = composeProfile({
|
||||
atoms: [atom({ id: "instructions", address: "walk.md#instructions" })],
|
||||
situation: "fresh", runtime: "claude", readFile,
|
||||
});
|
||||
expect(composed.pieces[0]?.text).toBe("## Instructions\ncontinue with the real instructions");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a missing file stops the compose with the atom named", () => {
|
||||
const graph = [atom({ id: "ghosty", address: "ghost.md#nope", order: 1 })];
|
||||
expect(() => composeProfile({ atoms: graph, situation: "fresh", runtime: "claude", readFile }))
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
// the threshold — the anti-noise direction);
|
||||
// stalled-after-claim — claimed, past threshold, zero substantive motion; evidence named;
|
||||
// parked — state=blocked only. Wake health is a separate `rig parked`
|
||||
// diagnosis; a wakeless blocked row still projects as parked.
|
||||
// diagnosis; a wakeless blocked row still projects as parked;
|
||||
// terminal — the obligation is closed (done/handed-off/failed/denied/canceled):
|
||||
// no pickup question is outstanding, regardless of claim age.
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -22,7 +24,7 @@ import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
const THRESHOLD_ENV = "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES";
|
||||
|
||||
interface PickupShape {
|
||||
state: "unclaimed" | "working" | "stalled-after-claim" | "parked";
|
||||
state: "unclaimed" | "working" | "stalled-after-claim" | "parked" | "terminal";
|
||||
evidence?: string;
|
||||
}
|
||||
const pickupOf = (item: QueueItem): PickupShape | undefined =>
|
||||
@@ -129,6 +131,39 @@ describe("S04 pickup receipts — derived, visible, threshold-honest", () => {
|
||||
expect(pickupOf(item)?.state).toBe("parked");
|
||||
});
|
||||
|
||||
it("TERMINAL: a claimed-and-closed done row past the threshold stops reading stalled-after-claim", async () => {
|
||||
const row = await mkRow();
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" });
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done", closureReason: "no-follow-on", transitionNote: "closed" });
|
||||
// Age the WHOLE history 60 min: the closed row must not fall through to the
|
||||
// stall timer once its last meaningful change is older than the threshold.
|
||||
ageClaim(row.qitemId, 60);
|
||||
db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ? AND state = 'done'")
|
||||
.run(new Date(Date.now() - 60 * 60_000).toISOString(), row.qitemId);
|
||||
const item = repo.getById(row.qitemId)!;
|
||||
expect(item.state).toBe("done");
|
||||
expect(pickupOf(item)?.state).toBe("terminal");
|
||||
});
|
||||
|
||||
it("TERMINAL: every closed state derives terminal, and a terminal row is never a stalled finding", async () => {
|
||||
const mod = (await import("../src/domain/queue-pickup.js")) as {
|
||||
derivePickup: (facts: {
|
||||
state: string; claimedAt: string | null; lastHeartbeat: string | null;
|
||||
postClaimMotionCount: number; lastMeaningfulAt?: string;
|
||||
}) => PickupShape;
|
||||
stalledPickupFinding: (item: QueueItem) => { kind: string } | null;
|
||||
};
|
||||
const old = new Date(Date.now() - 60 * 60_000).toISOString();
|
||||
for (const state of ["done", "failed", "denied", "canceled", "handed-off"]) {
|
||||
expect(mod.derivePickup({ state, claimedAt: old, lastHeartbeat: null, postClaimMotionCount: 0 }))
|
||||
.toEqual({ state: "terminal", evidence: `closed (${state})` });
|
||||
}
|
||||
// A terminal row never feeds the S02 sweep as a stalled-claim finding.
|
||||
const done = await mkRow();
|
||||
repo.update({ qitemId: done.qitemId, actorSession: "daemon@kernel", state: "done", closureReason: "no-follow-on", transitionNote: "closed" });
|
||||
expect(mod.stalledPickupFinding(repo.getById(done.qitemId)!)).toBeNull();
|
||||
});
|
||||
|
||||
it("THRESHOLD from CONFIG, observed by EFFECT: raising the threshold flips a stalled row back to working", async () => {
|
||||
const row = await mkRow();
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" });
|
||||
|
||||
@@ -181,6 +181,25 @@ describe("Rig CRUD routes", () => {
|
||||
expect(body.env.OPENRIG_SESSION_NAME).toBe("orch1-lead@rigged-buildout");
|
||||
});
|
||||
|
||||
it.each(["claude-code", "codex"])("attach-self checks the optional existing-node runtime guard %s", async (runtime) => {
|
||||
const rig = repo.createRig("runtime-guard");
|
||||
const node = repo.addNode(rig.id, "dev.worker", { runtime: "claude-code" });
|
||||
const res = await app.request(`/api/rigs/${rig.id}/attach-self`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ logicalId: "dev.worker", runtime, displayName: "worker@runtime-guard" }),
|
||||
});
|
||||
if (runtime === "claude-code") {
|
||||
expect(res.status).toBe(201);
|
||||
expect((await res.json()).nodeId).toBe(node.id);
|
||||
expect(sessionRegistry.getBindingForNode(node.id)).toBeTruthy();
|
||||
} else {
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).code).toBe("runtime_mismatch");
|
||||
expect(sessionRegistry.getBindingForNode(node.id)).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/rigs/:id/attach-self can self-attach a tmux-backed shell without discovery", async () => {
|
||||
const rig = repo.createRig("rigged-buildout");
|
||||
const node = repo.addNode(rig.id, "dev1.impl2", { runtime: "claude-code" });
|
||||
@@ -259,6 +278,18 @@ describe("Rig CRUD routes", () => {
|
||||
expect(body.error).toContain("either logicalId");
|
||||
});
|
||||
|
||||
it("attach-self still requires a runtime when creating a pod member", async () => {
|
||||
const rig = repo.createRig("missing-pod-runtime");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES (?, ?, ?, ?)").run("guard-pod", rig.id, "dev", "Dev");
|
||||
const res = await app.request(`/api/rigs/${rig.id}/attach-self`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ podNamespace: "dev", memberName: "worker" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(repo.getRig(rig.id)?.nodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("POST /api/rigs/:id/attach-self returns 409 when the target node is already bound", async () => {
|
||||
const rig = repo.createRig("rigged-buildout");
|
||||
const node = repo.addNode(rig.id, "orch1.lead", { runtime: "claude-code" });
|
||||
|
||||
@@ -11,10 +11,11 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { seatLifecycleService } from "../src/routes/seat.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
|
||||
// Never run a provider, shell, tmux or startup. OS confinement also denies them.
|
||||
// No provider, tmux or startup. Config-selection cases execute only a private
|
||||
// fake Claude through the generated shell command; other child calls are mocked.
|
||||
vi.mock("node:child_process", () => ({ execFile: vi.fn() }));
|
||||
// sandbox-exec deliberately denies execute eligibility too. Only that access
|
||||
// check is simulated; path resolution/stat/replacement use private real files.
|
||||
// Simulate eligibility for inert fixtures; path resolution/stat/replacement use
|
||||
// private real files. Config-selection cases additionally execute their fake.
|
||||
vi.mock("node:fs", async importOriginal => {
|
||||
const fs = await importOriginal<typeof import("node:fs")>();
|
||||
return { ...fs, accessSync: (file: string) => { if (!(fs.statSync(file).mode & 0o111)) throw Error("not executable"); } };
|
||||
@@ -65,6 +66,58 @@ function fixture() {
|
||||
const input = { seatRef: "owner@rig", mode: "auto", actor: "operator", reason: "deliberate choice" };
|
||||
|
||||
describe("S03 production managed capability selection", () => {
|
||||
it.each([
|
||||
["unset", undefined], ["relative", "./config"],
|
||||
["absolute", "/inert/explicit-config"], ["empty", ""],
|
||||
])("preserves %s config selection in help and the executed launch", async (_label, selected) => {
|
||||
const f = fixture();
|
||||
if (selected === undefined) delete f.env.CLAUDE_CONFIG_DIR;
|
||||
else f.env.CLAUDE_CONFIG_DIR = selected;
|
||||
const helpReceipt = path.join(f.cwd, "help-env.json");
|
||||
// Observe the actual child environment, using only synthetic credentials.
|
||||
writeFileSync(f.executable, `#!${process.execPath}\n
|
||||
const fs = require('node:fs');
|
||||
const keys = ['CLAUDE_CONFIG_DIR', 'HOME', 'ANTHROPIC_API_KEY', 'OPENRIG_HOME',
|
||||
'OPENRIG_NODE_ID', 'OPENRIG_RUNTIME', 'OPENRIG_SESSION_NAME', 'OPENRIG_OCCUPANT_GENERATION'];
|
||||
const env = Object.fromEntries(keys.filter(k => process.env[k] !== undefined).map(k => [k, process.env[k]]));
|
||||
if (process.argv.includes('--help')) {
|
||||
fs.writeFileSync(${JSON.stringify(helpReceipt)}, JSON.stringify(env));
|
||||
console.log(${JSON.stringify(help)});
|
||||
} else console.log(JSON.stringify({ env, args: process.argv.slice(2), cwd: process.cwd() }));
|
||||
`);
|
||||
const native = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
vi.mocked(execFile).mockImplementation(native.execFile);
|
||||
const prepared = await f.managed.prepare({ nodeId: "node", session: "seat", pane: "%1" }, "auto");
|
||||
const configDir = path.resolve(f.cwd, selected ?? path.join(f.env.HOME!, ".claude"));
|
||||
expect(prepared.configDir).toBe(configDir);
|
||||
const args = ["--permission-mode", "auto", "--name", "seat's literal name"];
|
||||
const command = prepared.command(args);
|
||||
expect(command).not.toContain(f.env.ANTHROPIC_API_KEY);
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
native.execFile("/bin/sh", ["-c", command], { encoding: "utf8", timeout: 3000,
|
||||
env: { ...f.env, CLAUDE_CONFIG_DIR: "/inert/not-the-managed-selection", OPENRIG_NODE_ID: "not-the-target" } },
|
||||
(error, out) => error ? reject(error) : resolve(out));
|
||||
});
|
||||
const launched = JSON.parse(stdout);
|
||||
const queried = JSON.parse(readFileSync(helpReceipt, "utf8"));
|
||||
for (const env of [queried, launched.env]) {
|
||||
if (selected === undefined) expect.soft(env).not.toHaveProperty("CLAUDE_CONFIG_DIR");
|
||||
else expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
|
||||
}
|
||||
if (selected === undefined) expect.soft(command).not.toContain("CLAUDE_CONFIG_DIR=");
|
||||
expect(queried).not.toHaveProperty("ANTHROPIC_API_KEY");
|
||||
expect(launched).toMatchObject({ cwd: f.cwd, args, env: {
|
||||
HOME: f.env.HOME, ANTHROPIC_API_KEY: f.env.ANTHROPIC_API_KEY, OPENRIG_HOME: f.env.OPENRIG_HOME,
|
||||
OPENRIG_NODE_ID: "node", OPENRIG_RUNTIME: "claude-code", OPENRIG_SESSION_NAME: "seat",
|
||||
OPENRIG_OCCUPANT_GENERATION: "generation-1",
|
||||
} });
|
||||
});
|
||||
it("detects unset config becoming explicit even when the storage directory stays the same", async () => {
|
||||
const f = fixture(); delete f.env.CLAUDE_CONFIG_DIR;
|
||||
const prepared = await f.managed.prepare({ nodeId: "node", session: "seat" }, "auto");
|
||||
f.env.CLAUDE_CONFIG_DIR = path.join(f.env.HOME!, ".claude");
|
||||
expect(() => prepared.command([])).toThrow(/context changed/);
|
||||
});
|
||||
it("selects and audits through the production service/adapter seam, without launch or secret copies", async () => {
|
||||
const f = fixture(); vi.stubEnv("PATH", f.daemonBin);
|
||||
expect(await f.service.setPermissions(input)).toEqual(expect.objectContaining({ ok: true, changed: true, to: { runtime: "claude-code", mode: "auto" } }));
|
||||
@@ -145,10 +198,12 @@ describe("S03 bound launch across existing paths", () => {
|
||||
expect(f.calls[0]).toContain("'OPENRIG_OCCUPANT_GENERATION=reserved-next'");
|
||||
expect(f.db.prepare("SELECT generation_uuid FROM occupant_tenures").get()).toEqual({ generation_uuid: "generation-1" });
|
||||
});
|
||||
it("captures the fork token only from the bound config root and preserves call-entry arguments", async () => {
|
||||
it.each(["unset", "explicit"])("captures the fork token from the %s config root and preserves call-entry arguments", async selection => {
|
||||
const f = fixture(); const reads: string[] = [];
|
||||
if (selection === "unset") delete f.env.CLAUDE_CONFIG_DIR;
|
||||
const configDir = selection === "unset" ? path.join(f.env.HOME!, ".claude") : path.join(f.cwd, "config");
|
||||
const adapter = new ClaudeCodeAdapter({ tmux: f.tmux, claudeManagedLaunch: f.managed, fsOps: { ...fsOps,
|
||||
homedir: "/different/daemon/home", exists: p => p === path.join(f.cwd,"config","sessions"), readdir: () => ["token.json"],
|
||||
homedir: "/different/daemon/home", exists: p => p === path.join(configDir,"sessions"), readdir: () => ["token.json"],
|
||||
readFile: p => { reads.push(p); return JSON.stringify({ name: "original", sessionId: "new-fork-token" }); } } });
|
||||
const opts = { name: "original", forkSource: { kind: "native_id" as const, value: "original-parent" } };
|
||||
vi.mocked(execFile).mockImplementation(((_f: string, _a: string[], _o: any, done: any) => {
|
||||
@@ -156,7 +211,7 @@ describe("S03 bound launch across existing paths", () => {
|
||||
opts.name = "changed"; opts.forkSource.value = "other-history"; done(null, help);
|
||||
}) as any);
|
||||
expect(await adapter.launchHarness(f.binding, opts)).toMatchObject({ ok: true, resumeToken: "new-fork-token" });
|
||||
expect(reads).toEqual([path.join(f.cwd,"config","sessions","token.json")]);
|
||||
expect(reads).toEqual([path.join(configDir,"sessions","token.json")]);
|
||||
expect(f.calls[0]).toContain("'--resume' 'original-parent'"); expect(f.calls[0]).toContain("'--name' 'original'");
|
||||
expect(f.calls[0]).not.toContain("other-history"); expect(f.calls[0]).not.toContain("other-model");
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import { observeCodexSandbox } from "../src/domain/permission-drift.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { DefaultOccupantInvalidator, type OccupantInvalidator } from "../src/domain/occupant-invalidator.js";
|
||||
|
||||
describe("SeatHandoverService", () => {
|
||||
let db: Database.Database;
|
||||
@@ -96,7 +98,7 @@ describe("SeatHandoverService", () => {
|
||||
return { runtime: "codex", launchHarness, checkReady } as unknown as RuntimeAdapter;
|
||||
}
|
||||
|
||||
function newService(adapter: TmuxAdapter = tmux()): SeatHandoverService {
|
||||
function newService(adapter: TmuxAdapter = tmux(), occupantInvalidator: OccupantInvalidator = { invalidateRetiringOccupant }): SeatHandoverService {
|
||||
return new SeatHandoverService({
|
||||
db,
|
||||
rigRepo,
|
||||
@@ -109,7 +111,7 @@ describe("SeatHandoverService", () => {
|
||||
runtimeAdapters: { codex: codexAdapter() },
|
||||
contextUsageStore: { readSidecar } as never,
|
||||
resumeTokenCapturer: { captureCodexThreadId } as never,
|
||||
occupantInvalidator: { invalidateRetiringOccupant },
|
||||
occupantInvalidator,
|
||||
activityOracle: { declareOccupantSwap },
|
||||
predecessorRecapResolver: resolvePredecessorRecap as never,
|
||||
readinessTimeoutMs: 50,
|
||||
@@ -1115,6 +1117,77 @@ describe("SeatHandoverService", () => {
|
||||
expect(durableRows()).toBe(before);
|
||||
});
|
||||
|
||||
// #141: a YAML re-import archives the stopped earlier generation, which keeps its binding to the seat's
|
||||
// canonical session name. A composer-launched successor reuses that name, so the archived shadow must not
|
||||
// reject the live seat's handover after the successor has already replaced the process.
|
||||
function seedArchivedGeneration(): void {
|
||||
const old = rigRepo.createRig("seat-rig");
|
||||
const oldNode = rigRepo.addNode(old.id, "dev.impl", { runtime: "codex", cwd: "/project" });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
|
||||
sessionRegistry.updateBinding(oldNode.id, { tmuxSession: "dev-impl@seat-rig", tmuxPane: "%0" });
|
||||
rigRepo.archiveRig(old.id);
|
||||
}
|
||||
|
||||
it.each(["fresh", "rebuild"] as const)("#141: an archived earlier generation's binding does not block a %s handover of the live seat", async (source) => {
|
||||
seedArchivedGeneration();
|
||||
const { node } = seedSeat();
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source });
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(respawnPane).toHaveBeenCalledTimes(1);
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
expect(sessionRegistry.getBindingForNode(node.id)?.tmuxSession).toBe("dev-impl@seat-rig");
|
||||
const nodeRow = db.prepare("SELECT handover_result FROM nodes WHERE id = ?").get(node.id) as { handover_result: string | null };
|
||||
expect(nodeRow.handover_result).toBe("complete");
|
||||
});
|
||||
|
||||
it("#141 control: another UNARCHIVED rig's binding to the seat's session name still blocks a fresh handover", async () => {
|
||||
seedSeat();
|
||||
const otherRig = rigRepo.createRig("other-rig");
|
||||
const otherNode = rigRepo.addNode(otherRig.id, "dev.other", { runtime: "codex" });
|
||||
sessionRegistry.updateBinding(otherNode.id, { tmuxSession: "dev-impl@seat-rig" });
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "fresh" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "successor_already_managed" });
|
||||
});
|
||||
|
||||
it("#141 control: an archived rig's binding still blocks a DISCOVERED successor of that name", async () => {
|
||||
seedSeat();
|
||||
const discovered = seedDiscovery();
|
||||
const archivedRig = rigRepo.createRig("archived-rig");
|
||||
const archivedNode = rigRepo.addNode(archivedRig.id, "dev.other", { runtime: "codex" });
|
||||
sessionRegistry.updateBinding(archivedNode.id, { tmuxSession: "successor-session" });
|
||||
rigRepo.archiveRig(archivedRig.id);
|
||||
const before = durableRows();
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: `discovered:${discovered.id}` });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "successor_already_managed" });
|
||||
expect(durableRows()).toBe(before);
|
||||
});
|
||||
|
||||
it("a successful fresh handover keeps the seat's blocked row's park timer live (real invalidator and repositories)", async () => {
|
||||
seedSeat();
|
||||
const gen = (session: string) => sessionRegistry.currentOccupantGenerationForSession(session);
|
||||
const queue = new QueueRepository(db, eventBus, { validateRig: () => true, resolveOccupantGeneration: gen });
|
||||
const jobs = new WatchdogJobsRepository(db, undefined, gen);
|
||||
queue.attachWatchdogJobsRepository(jobs);
|
||||
const row = await queue.create({ sourceSession: "orch-lead@seat-rig", destinationSession: "dev-impl@seat-rig", body: "work" } as never);
|
||||
queue.update({ qitemId: row.qitemId, actorSession: "dev-impl@seat-rig", state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: check CI", wakeAfterSeconds: 1800 } as never);
|
||||
const timer = (queue.getParkWakeStatus(row.qitemId) as { ref: string }).ref;
|
||||
const handover = newService(tmux(), new DefaultOccupantInvalidator({
|
||||
enforcer: { invalidateOccupant() {} }, contextUsage: { invalidateOccupantSidecar() {} }, watchdog: jobs, queue,
|
||||
}));
|
||||
|
||||
const result = await handover.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source: "fresh" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(jobs.getById(timer)?.state).toBe("active");
|
||||
expect((queue.getParkWakeStatus(row.qitemId) as { live: boolean }).live).toBe(true);
|
||||
});
|
||||
|
||||
it("fails before mutation when the seat has no current occupant", async () => {
|
||||
seedSeat({ withSession: false });
|
||||
const discovered = seedDiscovery();
|
||||
|
||||
@@ -72,8 +72,8 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
const { tmux, sendText, sendKeys } = tmuxWithPane(async () => shell);
|
||||
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux }), "dev-impl@my-rig");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
|
||||
expect(result.error).toContain(`bare ${shell.replace(/^-/, "")} shell`);
|
||||
expect(result).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(result.error).toContain(`${shell.replace(/^-/, "")} as the foreground command`);
|
||||
expect(result.error).toContain("No text was sent");
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
@@ -151,7 +151,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
];
|
||||
it.each(unproved)("shell label still refuses %s without input", async (_name, mutate) => {
|
||||
const { transport, sendText, sendKeys } = wrappedSeat(vi.fn(async () => mutate(wrapperProcesses())));
|
||||
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
|
||||
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
if (kind === "missing resume identity") sessionRegistry.clearResumeToken(session.id);
|
||||
if (kind === "changed process") listProcesses.mockResolvedValueOnce(wrapperProcesses()).mockResolvedValueOnce(wrapperProcesses().slice(0, -1));
|
||||
if (kind === "unavailable processes") listProcesses.mockRejectedValue(new Error("process observation failed"));
|
||||
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
|
||||
expect(await watchdogSend(transport, "dev-check@my-rig")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_unverified" });
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -223,7 +223,10 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
];
|
||||
it.each(unprovedClaude)("#197 retains refusal for %s", async (_label, mutate) => {
|
||||
const { transport, sendText, sendKeys } = wrappedClaude(vi.fn(async () => mutate(claudeProcesses())));
|
||||
expect(await transport.send("dev-check@my-rig", "existing review")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
|
||||
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();
|
||||
});
|
||||
@@ -234,7 +237,10 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
if (kind === "changed process") 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"));
|
||||
expect(await transport.send("dev-check@my-rig", "existing review")).toMatchObject({ ok: false, sent: false, reason: "target_runtime_not_running" });
|
||||
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();
|
||||
});
|
||||
@@ -266,7 +272,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("bare sh shell");
|
||||
expect(stored.lastNudgeResult).toContain("could not verify");
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
}
|
||||
@@ -322,7 +328,7 @@ describe("#142 the parked-owner wake records the refusal honestly and does not r
|
||||
|
||||
const first = await makeParkedOwnerConsumerPolicy(deps()).evaluate(job);
|
||||
expect(first.action).toBe("send");
|
||||
const refusal = `Refused: '${SEAT}' shows a bare zsh shell, so its claude-code runtime is not running. Text sent there would run as shell commands. Relaunch the seat first. No text was sent.`;
|
||||
const refusal = `Refused: '${SEAT}' reports zsh as the foreground command, but OpenRig could not verify its expected claude-code agent in the bound pane. The agent may still be running behind a wrapper. No text was sent.`;
|
||||
history.push({
|
||||
historyId: "h1", jobId: "job-1", evaluatedAt: new Date().toISOString(), outcome: "sent", skipReason: null,
|
||||
deliveryTargetSession: SEAT, deliveryStatus: "failed", deliveryMessage: "wake",
|
||||
@@ -332,7 +338,7 @@ describe("#142 the parked-owner wake records the refusal honestly and does not r
|
||||
const second = await makeParkedOwnerConsumerPolicy(deps()).evaluate(job);
|
||||
expect(second.action).toBe("skip");
|
||||
expect(JSON.stringify(second.notes)).toMatch(/already[-_]woken/);
|
||||
expect(transitions.some((t) => t.transitionNote?.startsWith(FAILED_PREFIX) && t.transitionNote.includes("runtime is not running"))).toBe(true);
|
||||
expect(nudges.some((n) => n.startsWith(NUDGE_FAIL_PREFIX) && n.includes("runtime is not running"))).toBe(true);
|
||||
expect(transitions.some((t) => t.transitionNote?.startsWith(FAILED_PREFIX) && t.transitionNote.includes("could not verify"))).toBe(true);
|
||||
expect(nudges.some((n) => n.startsWith(NUDGE_FAIL_PREFIX) && n.includes("could not verify"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -274,6 +274,33 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
expect(body[99]).toBe(99);
|
||||
});
|
||||
|
||||
it.each(["bytes=-0", "bytes=1000-"])("rejects unsatisfiable proof asset range %s", async (range) => {
|
||||
writeSlice(slicesRoot, "invalid-range-slice", { "README.md": "---\n---\n" });
|
||||
const dir = path.join(dogfoodRoot, "invalid-range-slice-20260504", "videos");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "demo.mp4"), Buffer.alloc(1000));
|
||||
const res = await app.request("/api/slices/invalid-range-slice/proof-asset/videos/demo.mp4", {
|
||||
headers: { Range: range },
|
||||
});
|
||||
expect(res.status).toBe(416);
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes */1000");
|
||||
});
|
||||
|
||||
it.each(["bytes=-1000", "bytes=-2000"])("serves a whole proof asset for long suffix %s", async (range) => {
|
||||
writeSlice(slicesRoot, "suffix-video-slice", { "README.md": "---\n---\n" });
|
||||
const dir = path.join(dogfoodRoot, "suffix-video-slice-20260504", "videos");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const bytes = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251));
|
||||
fs.writeFileSync(path.join(dir, "demo.mp4"), bytes);
|
||||
const res = await app.request("/api/slices/suffix-video-slice/proof-asset/videos/demo.mp4", {
|
||||
headers: { Range: range },
|
||||
});
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 0-999/1000");
|
||||
expect(res.headers.get("Content-Length")).toBe("1000");
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array(bytes));
|
||||
});
|
||||
|
||||
it("rejects path-traversal attempts with 400", async () => {
|
||||
writeSlice(slicesRoot, "trav-slice", { "README.md": "---\n---\n" });
|
||||
const dir = path.join(dogfoodRoot, "trav-slice-20260504");
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user