mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
36
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 | ||
|
|
8b5e948807 | ||
|
|
4dc4047cdb | ||
|
|
700af77ae0 | ||
|
|
3b1b335dbe | ||
|
|
e3ebd4ac2c | ||
|
|
100be9d646 |
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
|
||||
@@ -8,6 +8,34 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.6.3]
|
||||
|
||||
- Recognize running managed Claude Code seats behind OpenRig's shell wrappers
|
||||
using pane lineage, foreground process and native session identity checks.
|
||||
This repairs the 0.6.2 messaging refusal reported by
|
||||
[@dmelo](https://github.com/dmelo) in
|
||||
[#197](https://github.com/mvschwarz/openrig/issues/197)
|
||||
([#220](https://github.com/mvschwarz/openrig/pull/220)).
|
||||
- Clean up unusable snapshot helpers when their ownership is proven, instead
|
||||
of accumulating them on repeated attempts. Thanks to
|
||||
[@z4cc](https://github.com/z4cc) for
|
||||
[#188](https://github.com/mvschwarz/openrig/issues/188)
|
||||
([#189](https://github.com/mvschwarz/openrig/pull/189)).
|
||||
- Keep archived duplicate rigs out of seat-reference resolution and preserve
|
||||
another live seat's session and queue work when removing a stale node.
|
||||
Thanks to [@Farkinell](https://github.com/Farkinell) for
|
||||
[#174](https://github.com/mvschwarz/openrig/issues/174)
|
||||
([#181](https://github.com/mvschwarz/openrig/pull/181)).
|
||||
|
||||
- Protect proof media from artifact writes: reject binary artifact bodies and
|
||||
require an explicit `--replace` to overwrite an existing Markdown artifact
|
||||
([#177](https://github.com/mvschwarz/openrig/pull/177)). Repository script tests
|
||||
also run serially to prevent interference from shared build outputs
|
||||
([#221](https://github.com/mvschwarz/openrig/pull/221)).
|
||||
|
||||
See [0.6.3 release notes](docs/releases/v0.6.3.md) for the verification limits.
|
||||
Source regression coverage does not establish installed Claude/Fedora delivery.
|
||||
|
||||
## [0.6.2]
|
||||
|
||||
- Start with two Claude Code agents, two Codex agents, or a Claude owner and
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# OpenRig 0.6.3 — Managed Claude messaging repair
|
||||
|
||||
This patch repairs a 0.6.2 regression that refused messages to running Claude
|
||||
Code seats launched with an explicit permission mode. It also fixes snapshot
|
||||
helper cleanup and interference from archived rigs with duplicate names.
|
||||
|
||||
## Managed Claude seats can receive messages
|
||||
|
||||
OpenRig's managed launch can leave a shell wrapper in front of Claude Code.
|
||||
The delivery check previously treated that wrapper as a bare shell and refused
|
||||
`rig send`, even though Claude was running beneath it.
|
||||
|
||||
The check now recognizes a stable, foreground Claude process descended from the
|
||||
bound pane, with the expected native session identity. A bare shell alone still
|
||||
does not qualify. Fork arguments that identify the parent rather than the
|
||||
current occupant remain insufficient evidence.
|
||||
|
||||
Thanks to [@dmelo](https://github.com/dmelo) for the detailed
|
||||
[#197 report](https://github.com/mvschwarz/openrig/issues/197), addressed by
|
||||
[#220](https://github.com/mvschwarz/openrig/pull/220).
|
||||
|
||||
## Snapshot helpers and archived rigs
|
||||
|
||||
- Clean up a newly created snapshot helper when its input target cannot be
|
||||
established and its ownership can still be proven. This prevents unusable
|
||||
`rigged-refresh-*` helpers accumulating on repeated attempts. If ownership
|
||||
cannot be established, preserve the session and report the limitation.
|
||||
Thanks to [@z4cc](https://github.com/z4cc) for
|
||||
[#188](https://github.com/mvschwarz/openrig/issues/188), addressed by
|
||||
[#189](https://github.com/mvschwarz/openrig/pull/189).
|
||||
- Exclude archived rigs from seat-reference resolution, prefer unarchived
|
||||
delivery targets, and preserve another live seat's session and queue work
|
||||
when removing a stale duplicate node. Removal reports whose session it kept.
|
||||
Thanks to [@Farkinell](https://github.com/Farkinell) for
|
||||
[#174](https://github.com/mvschwarz/openrig/issues/174), addressed by
|
||||
[#181](https://github.com/mvschwarz/openrig/pull/181).
|
||||
|
||||
## Proof media and repository checks
|
||||
|
||||
`rig proof add` rejects binary files supplied as artifact text, requires a
|
||||
Markdown artifact name, and requires `--replace` before overwriting an existing
|
||||
artifact. Attach screenshots and other binary evidence with `--media`. This
|
||||
prevents a proof write from replacing media with a Markdown body
|
||||
([#177](https://github.com/mvschwarz/openrig/pull/177)).
|
||||
|
||||
Repository script tests now run serially because packaging tests rebuild daemon
|
||||
output that other tests import. This removes the reproduced shared-output race
|
||||
without weakening those tests
|
||||
([#221](https://github.com/mvschwarz/openrig/pull/221)).
|
||||
|
||||
## Upgrade and verification scope
|
||||
|
||||
Node.js 22 and 24 remain supported. This patch changes no dependencies, engine
|
||||
requirements or database migrations. Use the
|
||||
[existing upgrade procedure](../../skills/_canonical/core/openrig-upgrade/SKILL.md).
|
||||
|
||||
The fixes have source reviews and focused regression coverage. The Claude
|
||||
process checks use synthetic process metadata; an installed Claude/Fedora send
|
||||
has not been demonstrated by that evidence. These checks do not establish
|
||||
native receipt, automatic recovery, or protection against a process exiting
|
||||
between verification and input. Final package and publication verification
|
||||
remain separate from this source preparation.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7013,7 +7013,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7047,7 +7047,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7088,7 +7088,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -13,7 +13,7 @@
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"test": "npm run test:repo && npm run test:workspaces",
|
||||
"test:repo": "npm run build -w packages/daemon && node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check && node scripts/generate-context-packs.mjs --check",
|
||||
"test:repo": "npm run build -w packages/daemon && node --test --test-concurrency=1 scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check && node scripts/generate-context-packs.mjs --check",
|
||||
"test:workspaces": "npm run test -w packages/daemon -w packages/cli -w packages/tui",
|
||||
"test:ui": "npm run test -w packages/ui",
|
||||
"typecheck:prep": "npm run build -w packages/daemon",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,11 @@ export const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEA
|
||||
|
||||
/** Video extensions for the C8 UX advisory (screencast evidence). */
|
||||
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv"]);
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".pdf",
|
||||
".mp3", ".wav", ".ogg", ".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv",
|
||||
".zip", ".gz", ".7z", ".exe",
|
||||
]);
|
||||
|
||||
export interface C1Header {
|
||||
slice: string;
|
||||
@@ -183,7 +188,8 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
.option("--slice-id <dot-id>", "C1 slice dot-ID (defaults to the slice frontmatter id)")
|
||||
.option("--file <path>", "Artifact body from a file (mutually exclusive with --body)")
|
||||
.option("--body <text>", "Artifact body inline (mutually exclusive with --file)")
|
||||
.option("--name <filename>", "Artifact filename in proof/ (defaults to the --file basename, else <artifact-type>-<verdict>-<UTC>.md)")
|
||||
.option("--name <filename>", "Markdown artifact filename in proof/ (defaults to the --file stem plus .md, else <artifact-type>-<verdict>-<UTC>.md)")
|
||||
.option("--replace", "Explicitly replace an existing Markdown artifact")
|
||||
.option("--evidences <refs>", "D2 attestation: comma-separated proof-contract item refs this artifact covers (item text or 1-based index)")
|
||||
.option("--self-check <text>", "D2 attestation: the agent's assertion that it LOOKED at the evidence and confirmed it shows the claim")
|
||||
.option("--media <refs>", "Corrective §3.4: comma-separated media refs (relative to the slice proof/ dir) this drop stands behind — appended to the artifact body as markdown refs so the composer curates them into delivered.items[].proof")
|
||||
@@ -198,6 +204,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
file?: string;
|
||||
body?: string;
|
||||
name?: string;
|
||||
replace?: boolean;
|
||||
evidences?: string;
|
||||
selfCheck?: string;
|
||||
media?: string;
|
||||
@@ -228,7 +235,23 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
action: "Point --file at the evidence file, or use --body.",
|
||||
});
|
||||
}
|
||||
body = fs.readFileSync(opts.file, "utf8");
|
||||
const input = fs.readFileSync(opts.file);
|
||||
if (BINARY_EXTENSIONS.has(path.extname(opts.file).toLowerCase()) || input.includes(0)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `--file ${opts.file} is binary, not an artifact body.`,
|
||||
consequence: "The artifact was NOT dropped and the source file was not changed.",
|
||||
action: "Attach screenshots and other binary evidence with --media instead.",
|
||||
});
|
||||
}
|
||||
try {
|
||||
body = new TextDecoder("utf-8", { fatal: true }).decode(input);
|
||||
} catch {
|
||||
throw new ScopeCliError({
|
||||
fact: `--file ${opts.file} is not valid UTF-8 text.`,
|
||||
consequence: "The artifact was NOT dropped and the source file was not changed.",
|
||||
action: "Use a UTF-8 text file for --file, or attach binary evidence with --media.",
|
||||
});
|
||||
}
|
||||
} else if (opts.body) {
|
||||
body = opts.body;
|
||||
}
|
||||
@@ -402,7 +425,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
// Write the artifact: YAML frontmatter + body into proof/.
|
||||
const proofDir = path.join(slice.absPath, "proof");
|
||||
const defaultName = `${opts.artifactType}-${opts.verdict}-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
|
||||
const fileName = opts.name ?? (opts.file ? path.basename(opts.file) : defaultName);
|
||||
const fileName = opts.name ?? (opts.file ? `${path.parse(opts.file).name}.md` : defaultName);
|
||||
// rev1-r2 BLOCKING fix (a7dedd93 review): --name is a FILENAME, never
|
||||
// a path. Reject separators / dot-dot / absolute shapes BEFORE any
|
||||
// filesystem effect, so the drop can only land inside proof/ (the
|
||||
@@ -415,6 +438,13 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
action: "Pass a bare filename like qa-clear.md; the drop path owns the directory.",
|
||||
});
|
||||
}
|
||||
if (!fileName.toLowerCase().endsWith(".md")) {
|
||||
throw new ScopeCliError({
|
||||
fact: `--name '${fileName}' is not a Markdown artifact filename.`,
|
||||
consequence: "The artifact was NOT dropped; proof media cannot be replaced by a Markdown body.",
|
||||
action: "Use a .md name for the artifact and attach binary files with --media.",
|
||||
});
|
||||
}
|
||||
const target = path.resolve(proofDir, fileName);
|
||||
// Defense-in-depth: even a name that slips the shape check must
|
||||
// resolve INSIDE proof/ (same containment discipline as
|
||||
@@ -428,7 +458,16 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
}
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
const frontmatter = YAML.stringify(header).trimEnd();
|
||||
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, "utf8");
|
||||
try {
|
||||
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, { encoding: "utf8", flag: opts.replace ? "w" : "wx" });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
throw new ScopeCliError({
|
||||
fact: `Proof artifact '${fileName}' already exists.`,
|
||||
consequence: "The existing artifact was not changed.",
|
||||
action: "Choose another --name, or pass --replace to deliberately update this Markdown artifact.",
|
||||
});
|
||||
}
|
||||
|
||||
// Echo the parsed header — the seat sees what the composer will see.
|
||||
const echo = {
|
||||
|
||||
@@ -51,6 +51,9 @@ export function removeCommand(depsOverride?: StatusDeps): Command {
|
||||
}
|
||||
|
||||
console.log(`Removed node ${res.data["logicalId"]} from rig ${res.data["rigId"]} (${res.data["sessionsKilled"]} session killed)`);
|
||||
if (typeof res.data["sessionKeptFor"] === "string") {
|
||||
console.log(`Session kept: owned by ${res.data["sessionKeptFor"]}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ type ShrinkResponse = {
|
||||
nodeId: string;
|
||||
status: "removed" | "failed";
|
||||
sessionsKilled: number;
|
||||
sessionKeptFor?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
error?: string;
|
||||
@@ -74,7 +75,8 @@ export function shrinkCommand(depsOverride?: StatusDeps): Command {
|
||||
for (const node of res.data.nodes ?? []) {
|
||||
const icon = node.status === "removed" ? "OK" : "FAIL";
|
||||
const error = node.error ? ` — ${node.error}` : "";
|
||||
console.log(` [${icon}] ${node.logicalId}${error}`);
|
||||
const kept = node.sessionKeptFor ? ` (session kept: owned by ${node.sessionKeptFor})` : "";
|
||||
console.log(` [${icon}] ${node.logicalId}${kept}${error}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -83,6 +85,9 @@ export function shrinkCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log(
|
||||
`Removed pod ${res.data.namespace} from rig ${res.data.rigId} (${res.data.removedLogicalIds?.length ?? 0} node(s), ${res.data.sessionsKilled} session killed)`
|
||||
);
|
||||
for (const node of res.data.nodes ?? []) {
|
||||
if (node.sessionKeptFor) console.log(` ${node.logicalId}: session kept, owned by ${node.sessionKeptFor}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,52 @@ describe("rig proof add (fs-level, temp workspace)", () => {
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a screenshot passed as --file without changing its bytes", async () => {
|
||||
const proofDir = path.join(sliceDir, "proof");
|
||||
fs.mkdirSync(proofDir);
|
||||
const screenshot = path.join(proofDir, "shot.png");
|
||||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]);
|
||||
fs.writeFileSync(screenshot, bytes);
|
||||
await run([
|
||||
"add", "19-signal-layer", "--mission", "release-x",
|
||||
"--artifact-type", "qa", "--verdict", "CLEAR",
|
||||
"--candidate-sha", "abc1234", "--money-evidence", "m",
|
||||
"--file", screenshot,
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(fs.readFileSync(screenshot)).toEqual(bytes);
|
||||
expect(fs.readdirSync(proofDir)).toEqual(["shot.png"]);
|
||||
expect(errs.join("\n")).toContain("--media");
|
||||
|
||||
process.exitCode = undefined;
|
||||
await run(baseArgs(["--name", "shot.png"]));
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(fs.readFileSync(screenshot)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("uses a Markdown name for a text file and requires --replace to overwrite it", async () => {
|
||||
const source = path.join(workRoot, "notes.txt");
|
||||
fs.writeFileSync(source, "first proof");
|
||||
const fromFile = [
|
||||
"add", "19-signal-layer", "--mission", "release-x",
|
||||
"--artifact-type", "qa", "--verdict", "CLEAR",
|
||||
"--candidate-sha", "abc1234", "--money-evidence", "m",
|
||||
"--file", source,
|
||||
];
|
||||
await run(fromFile);
|
||||
const target = path.join(sliceDir, "proof", "notes.md");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(target, "utf8")).toContain("first proof");
|
||||
fs.writeFileSync(source, "revised proof");
|
||||
await run(fromFile);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(fs.readFileSync(target, "utf8")).not.toContain("revised proof");
|
||||
process.exitCode = undefined;
|
||||
await run([...fromFile, "--replace"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(target, "utf8")).toContain("revised proof");
|
||||
});
|
||||
|
||||
it("out-of-set verdict is rejected naming the allowed values; nothing written; exit 1", async () => {
|
||||
await run([
|
||||
"add", "19-signal-layer", "--mission", "release-x",
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// #174: when removal keeps a session that belongs to another rig's live seat, the human output of
|
||||
// `rig remove` and `rig shrink` says so (JSON already carries `sessionKeptFor`).
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import { removeCommand } from "../src/commands/remove.js";
|
||||
import { shrinkCommand } from "../src/commands/shrink.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"DELETE /api/rigs/rig-1/nodes/lead.planner": {
|
||||
ok: true, rigId: "rig-1", nodeId: "node-9", logicalId: "lead.planner",
|
||||
sessionsKilled: 0, sessionKeptFor: "lead.planner@live-rig", reroutedQitemIds: [],
|
||||
},
|
||||
"DELETE /api/rigs/rig-1/nodes/dev.impl": {
|
||||
ok: true, rigId: "rig-1", nodeId: "node-1", logicalId: "dev.impl", sessionsKilled: 1, reroutedQitemIds: [],
|
||||
},
|
||||
"DELETE /api/rigs/rig-1/pods/kept": {
|
||||
ok: true, status: "ok", rigId: "rig-1", podId: "pod-9", namespace: "kept",
|
||||
removedLogicalIds: ["kept.lead"], sessionsKilled: 0, reroutedQitemIds: [],
|
||||
nodes: [{ logicalId: "kept.lead", nodeId: "node-9", status: "removed", sessionsKilled: 0, sessionKeptFor: "kept.lead@live-rig" }],
|
||||
},
|
||||
};
|
||||
|
||||
describe("#174 remove/shrink human output names a kept session", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const body = RESPONSES[`${req.method} ${req.url}`];
|
||||
res.writeHead(body ? 200 : 404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body ?? { error: "not found" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => { server.listen(0, "127.0.0.1", resolve); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
function deps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => p === STATE_FILE
|
||||
? JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-09-30T00:00:00Z" } as DaemonState)
|
||||
: null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
},
|
||||
clientFactory: () => new DaemonClient(`http://127.0.0.1:${port}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function run(command: Command, args: string[]): Promise<{ output: string; exitCode: number | undefined }> {
|
||||
const logs: string[] = [];
|
||||
const log = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
|
||||
const err = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
|
||||
process.exitCode = undefined;
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(command);
|
||||
try {
|
||||
await program.parseAsync(["node", "rig", ...args]);
|
||||
return { output: logs.join("\n"), exitCode: process.exitCode };
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
err.mockRestore();
|
||||
process.exitCode = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
it("remove says the session was kept and for whom", async () => {
|
||||
const { output, exitCode } = await run(removeCommand(deps()), ["remove", "rig-1", "lead.planner"]);
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(output).toContain("Removed node lead.planner from rig rig-1 (0 session killed)");
|
||||
expect(output).toContain("Session kept: owned by lead.planner@live-rig");
|
||||
});
|
||||
|
||||
it("control: remove prints no kept line when it killed its own session", async () => {
|
||||
const { output } = await run(removeCommand(deps()), ["remove", "rig-1", "dev.impl"]);
|
||||
expect(output).toContain("Removed node dev.impl from rig rig-1 (1 session killed)");
|
||||
expect(output).not.toContain("Session kept");
|
||||
});
|
||||
|
||||
it("shrink names each node whose session was kept", async () => {
|
||||
const { output, exitCode } = await run(shrinkCommand(deps()), ["shrink", "rig-1", "kept"]);
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(output).toContain("kept.lead: session kept, owned by kept.lead@live-rig");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,13 +289,49 @@ export class TmuxAdapter {
|
||||
* never authority over a pre-existing or registry-managed target. */
|
||||
async createProbeSession(name: string, cwd?: string): Promise<TmuxResult> {
|
||||
if (this.deliveryGuard?.maybeTarget(name)) return { ok: false, code: "guard_target_managed", message: "A probe cannot reuse a managed seat." };
|
||||
const created = await this.createSessionUnchecked(name, cwd);
|
||||
if (!created.ok) return created;
|
||||
// #188: the create itself reports the new session's id and creation time, the only identity a rollback
|
||||
// uses. An id alone is unique only for one server's lifetime.
|
||||
let created: { id: string; at: string } | null;
|
||||
try {
|
||||
const out = await this.exec(`tmux new-session -d -P -F ${shellQuote("#{session_id} #{session_created}")} -s ${shellQuote(name)}${cwd != null ? ` -c ${shellQuote(cwd)}` : ""}`);
|
||||
const match = /^(\$\d+) (\d+)$/.exec(out.trim());
|
||||
created = match ? { id: match[1]!, at: match[2]! } : null;
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
}
|
||||
let message = "New probe pane could not be established; no input written.";
|
||||
let pane: string | null = null;
|
||||
try {
|
||||
const panes = await this.listPanes(name);
|
||||
if (panes.length === 1) { this.freshProbes.set(name, panes[0]!.id); this.freshProbes.set(panes[0]!.id, panes[0]!.id); return created; }
|
||||
pane = panes.length === 1 ? panes[0]!.id : null;
|
||||
// #188: a fresh tmux server reuses pane ids, so a stale seat binding can name the new pane. That
|
||||
// probe could not be cleaned up safely later, so it is not used.
|
||||
if (pane && this.deliveryGuard?.maybeTarget(pane)) message = "New probe pane is named by a managed record; no input written.";
|
||||
else if (pane) { this.freshProbes.set(name, pane); this.freshProbes.set(pane, pane); return { ok: true }; }
|
||||
} catch { /* no target proof, no input */ }
|
||||
return { ok: false, code: "guard_target_unknown", message: "New probe pane could not be established; no input written." };
|
||||
// #188: remove the unusable helper by the id its own create returned, unless a managed record has
|
||||
// since claimed its name (a binding naming only the reused pane id, under another session name, is
|
||||
// stale: that pane belongs to this session). A name reused by another session has a different id.
|
||||
const byPane = pane ? this.deliveryGuard?.maybeTarget(pane) : null;
|
||||
if (!created || this.deliveryGuard?.maybeTarget(name) || byPane?.session === name) {
|
||||
return { ok: false, code: "guard_target_unknown", message: `${message} The helper session was left in place: its ownership could not be proven.` };
|
||||
}
|
||||
const removed = await this.removeCreatedSession(name, created);
|
||||
if (!removed.ok && removed.code !== "session_not_found") message += ` The helper session was not removed: ${removed.message}`;
|
||||
return { ok: false, code: "guard_target_unknown", message };
|
||||
}
|
||||
|
||||
/** #188: kill the session this adapter created only while its id still names that session. tmux checks
|
||||
* the name and creation time and kills in one command, so a later server that reuses the id is safe. */
|
||||
private async removeCreatedSession(name: string, created: { id: string; at: string }): Promise<TmuxResult> {
|
||||
const same = `#{&&:#{==:#{session_name},${name}},#{==:#{session_created},${created.at}}}`;
|
||||
try {
|
||||
const out = await this.exec(`tmux if-shell -F -t ${shellQuote(created.id)} ${shellQuote(same)} ${shellQuote(`kill-session -t '${created.id}'`)} ${shellQuote("display-message -p kept")}`);
|
||||
if (out.includes("kept")) return { ok: false, code: "guard_target_changed", message: `its id ${created.id} no longer names it (it may already be gone); nothing was killed.` };
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false): Promise<TmuxResult> {
|
||||
@@ -274,7 +342,8 @@ export class TmuxAdapter {
|
||||
if (probePane && !guard.maybeTarget(target)) {
|
||||
const panes = await this.listPanes(target);
|
||||
if (panes.length !== 1 || panes[0]!.id !== probePane) throw new Error("Private probe target changed; no input written.");
|
||||
return write(probePane, () => {});
|
||||
// Awaited so a refusal inside the write (#188: the probe kill) returns as a result, not a throw.
|
||||
return await write(probePane, () => {});
|
||||
}
|
||||
const created = this.freshManaged.get(target);
|
||||
const identity = created?.nodeId ?? target;
|
||||
@@ -313,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++) {
|
||||
@@ -335,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 [];
|
||||
@@ -345,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 [];
|
||||
@@ -355,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 [];
|
||||
@@ -378,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)) {
|
||||
@@ -420,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);
|
||||
@@ -458,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);
|
||||
@@ -531,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);
|
||||
@@ -541,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);
|
||||
@@ -557,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);
|
||||
@@ -569,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 () => {
|
||||
@@ -585,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);
|
||||
@@ -624,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);
|
||||
@@ -643,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);
|
||||
@@ -656,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);
|
||||
@@ -665,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));
|
||||
}
|
||||
@@ -687,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;
|
||||
@@ -701,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 {
|
||||
@@ -712,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 {
|
||||
@@ -727,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,
|
||||
@@ -742,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);
|
||||
@@ -753,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);
|
||||
@@ -765,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;
|
||||
@@ -780,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;
|
||||
@@ -795,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);
|
||||
@@ -820,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);
|
||||
@@ -837,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);
|
||||
@@ -853,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 {
|
||||
@@ -864,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;
|
||||
@@ -894,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();
|
||||
@@ -917,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 [];
|
||||
@@ -932,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;
|
||||
|
||||
@@ -68,6 +68,24 @@ function codexResumeToken(args: string[]): string | null | undefined {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Managed fresh/resume launches name the current Claude identity explicitly.
|
||||
// A fork's --resume names its parent, so it cannot prove the new occupant.
|
||||
function claudeSessionToken(args: string[]): string | null {
|
||||
let token: string | null = null;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if (index === 0 && /^\(\d+\.\d+\.\d+[^)]*\)$/.test(arg)) continue;
|
||||
if (["--permission-mode", "--model", "--name"].includes(arg)) { index += 1; continue; }
|
||||
if (/^--(?:permission-mode|model|name)=/.test(arg) || arg === "--dangerously-skip-permissions") continue;
|
||||
const identity = arg.match(/^--(?:session-id|resume)(?:=(.*))?$/);
|
||||
if (!identity) return null; // Unknown argv is not positive identity proof.
|
||||
const value = identity[1] ?? args[++index];
|
||||
if (token !== null || !value || value.startsWith("-")) return null;
|
||||
token = value;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Require a live process in the pane's own lineage whose argv names both the
|
||||
* declared runtime and the exact native resume identity. */
|
||||
export function findExactNativeResumeProcess(
|
||||
@@ -76,7 +94,7 @@ export function findExactNativeResumeProcess(
|
||||
runtime: string | null,
|
||||
expectedToken: string,
|
||||
): NativeProcessRow | null {
|
||||
if (runtime === "codex") return selectCodexProcess(processes, panePid, expectedToken, true)?.process ?? null;
|
||||
if (runtime === "codex") return selectNativeProcess(processes, panePid, expectedToken, true)?.process ?? null;
|
||||
if (runtime !== "claude-code") return null;
|
||||
const byParent = new Map<number, NativeProcessRow[]>();
|
||||
for (const process of processes) {
|
||||
@@ -103,26 +121,35 @@ 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 []; }
|
||||
}
|
||||
|
||||
export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProcessRow[]>;
|
||||
export type CodexProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
|
||||
type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
|
||||
export type CodexProcessObservation = NativeProcessObservation;
|
||||
|
||||
function selectCodexProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false): CodexProcessObservation | null {
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex"): NativeProcessObservation | null {
|
||||
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
||||
const root = byPid.get(panePid);
|
||||
if (byPid.size !== rows.length || !root?.startedAt || !root.tpgid || root.tpgid <= 0) return null;
|
||||
const matches: { process: NativeProcessRow; chain: NativeProcessRow[] }[] = [];
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
for (const row of rows) {
|
||||
if (row.executableName !== "codex" || executableName(tokens(row.command)[0] ?? "") !== "codex"
|
||||
const osExecutable = runtime === "claude-code" ? executableName(row.executableName ?? "") : row.executableName;
|
||||
if (osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable
|
||||
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
const chain: NativeProcessRow[] = [];
|
||||
const visited = new Set<number>();
|
||||
@@ -136,31 +163,46 @@ function selectCodexProcess(rows: NativeProcessRow[], panePid: number, expectedT
|
||||
}
|
||||
if (matches.length !== 1) return null;
|
||||
const { process, chain } = matches[0]!;
|
||||
const resumeToken = codexResumeToken(tokens(process.command).slice(1));
|
||||
if (requireResume && !expectedToken) return null;
|
||||
if ((requireResume || (expectedToken !== undefined && resumeToken !== undefined))
|
||||
&& (!expectedToken || resumeToken !== expectedToken)) return null;
|
||||
if (runtime === "claude-code") {
|
||||
if (!expectedToken || claudeSessionToken(tokens(process.command).slice(1)) !== expectedToken) return null;
|
||||
} else {
|
||||
const resumeToken = codexResumeToken(tokens(process.command).slice(1));
|
||||
if (requireResume && !expectedToken) return null;
|
||||
if ((requireResume || (expectedToken !== undefined && resumeToken !== undefined))
|
||||
&& (!expectedToken || resumeToken !== expectedToken)) return null;
|
||||
}
|
||||
return { panePid, process, fingerprint: JSON.stringify(chain.map((row) => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command])) };
|
||||
}
|
||||
|
||||
export async function observeCodexPaneProcess(input: {
|
||||
async function observeNativePaneProcess(input: {
|
||||
target: string;
|
||||
tmux: { getPanePid(target: string): Promise<number | null> };
|
||||
listProcesses?: NativeProcessLister;
|
||||
expectedToken?: string | null;
|
||||
requireResume?: boolean;
|
||||
}): Promise<CodexProcessObservation | null> {
|
||||
}, runtime: NativeRuntime): Promise<NativeProcessObservation | null> {
|
||||
try {
|
||||
const pid = await input.tmux.getPanePid(input.target);
|
||||
if (!pid) return null;
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
return selectCodexProcess(rows, pid, input.expectedToken, input.requireResume);
|
||||
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function observeCodexPaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<CodexProcessObservation | null> {
|
||||
return observeNativePaneProcess(input, "codex");
|
||||
}
|
||||
|
||||
export async function verifyCodexPaneProcess(input: Parameters<typeof observeCodexPaneProcess>[0]): Promise<CodexProcessObservation | null> {
|
||||
const first = await observeCodexPaneProcess(input);
|
||||
if (!first) return null;
|
||||
const second = await observeCodexPaneProcess(input);
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
|
||||
export async function verifyClaudePaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<NativeProcessObservation | null> {
|
||||
const first = await observeNativePaneProcess(input, "claude-code");
|
||||
if (!first) return null;
|
||||
const second = await observeNativePaneProcess(input, "claude-code");
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DiscoveryRepository } from "./discovery-repository.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import type { QueueRepository } from "./queue-repository.js";
|
||||
import { findOtherSessionOwner } from "./session-owner.js";
|
||||
|
||||
type ClaimedSessionRow = {
|
||||
session_id: string;
|
||||
@@ -63,6 +64,8 @@ export type RemoveNodeResult =
|
||||
nodeId: string;
|
||||
logicalId: string;
|
||||
sessionsKilled: number;
|
||||
/** #174: the session name belonged to another node's seat (logical id @ rig), so it was kept. */
|
||||
sessionKeptFor?: string;
|
||||
fallbackDestination?: string;
|
||||
reroutedQitemIds: string[];
|
||||
}
|
||||
@@ -384,8 +387,15 @@ export class RigLifecycleService {
|
||||
if (invalidFallback) return invalidFallback;
|
||||
}
|
||||
|
||||
// #174: a session name can be reused by another rig's live seat (for example an archived duplicate
|
||||
// of a live rig). When another unarchived node owns the name, that session and the queue work
|
||||
// addressed to it are the other seat's: removal neither kills it nor routes its work. Owners in
|
||||
// archived rigs don't count, since archiving keeps their stale bindings.
|
||||
const sessionOwner = node.latest_session_name
|
||||
? findOtherSessionOwner(this.db, node.latest_session_name, node.node_id, { ignoreArchived: true })
|
||||
: null;
|
||||
const activeQitemIds = this.activeQitemIdsForSessionNames(
|
||||
node.latest_session_name ? [node.latest_session_name] : [],
|
||||
node.latest_session_name && !sessionOwner ? [node.latest_session_name] : [],
|
||||
);
|
||||
if (activeQitemIds.length > 0 && fallbackDestination === undefined) {
|
||||
return {
|
||||
@@ -407,9 +417,10 @@ export class RigLifecycleService {
|
||||
}
|
||||
|
||||
const preserveDetachedClaimedSession = node.latest_session_origin === "claimed" && node.latest_session_status === "detached";
|
||||
const keepSession = preserveDetachedClaimedSession || sessionOwner !== null;
|
||||
|
||||
let sessionsKilled = 0;
|
||||
if (node.latest_session_name && !preserveDetachedClaimedSession) {
|
||||
if (node.latest_session_name && !keepSession) {
|
||||
const kill = await this.tmuxAdapter?.killSession(node.latest_session_name);
|
||||
if (kill && !kill.ok && kill.code !== "session_not_found") {
|
||||
return {
|
||||
@@ -426,7 +437,7 @@ export class RigLifecycleService {
|
||||
const persisted: Array<{ type: "session.detached" | "node.removed"; seq: number; createdAt: string }> = [];
|
||||
let rosterEvent: ReturnType<EventBus["persistWithinTransaction"]> | null = null;
|
||||
const tx = this.db.transaction(() => {
|
||||
if (node.latest_session_name && !preserveDetachedClaimedSession) {
|
||||
if (node.latest_session_name && !keepSession) {
|
||||
const detached = this.eventBus.persistWithinTransaction({
|
||||
type: "session.detached",
|
||||
rigId,
|
||||
@@ -480,6 +491,7 @@ export class RigLifecycleService {
|
||||
nodeId: node.node_id,
|
||||
logicalId: node.logical_id,
|
||||
sessionsKilled,
|
||||
...(sessionOwner ? { sessionKeptFor: `${sessionOwner.logical_id}@${sessionOwner.rig_name}` } : {}),
|
||||
...(fallbackDestination !== undefined ? { fallbackDestination } : {}),
|
||||
reroutedQitemIds: activeQitemIds,
|
||||
};
|
||||
@@ -521,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 {
|
||||
@@ -549,6 +567,7 @@ export class RigLifecycleService {
|
||||
logicalId: string;
|
||||
status: "removed" | "failed";
|
||||
sessionsKilled: number;
|
||||
sessionKeptFor?: string;
|
||||
error?: string;
|
||||
}> = [];
|
||||
for (const node of nodes) {
|
||||
@@ -613,6 +632,7 @@ export class RigLifecycleService {
|
||||
logicalId: removed.logicalId,
|
||||
status: "removed",
|
||||
sessionsKilled: removed.sessionsKilled,
|
||||
...(removed.sessionKeptFor ? { sessionKeptFor: removed.sessionKeptFor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -517,6 +517,15 @@ export class RigRepository {
|
||||
return rows.map((r) => this.rowToRig(r));
|
||||
}
|
||||
|
||||
/** #174: seat-ref resolution sees only unarchived rigs, as default reads do since migration 042. */
|
||||
findUnarchivedRigsByName(name: string): Rig[] {
|
||||
const archived = this.hasRigColumn("archived_at") ? " AND archived_at IS NULL" : "";
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM rigs WHERE name = ?${archived} ORDER BY created_at`)
|
||||
.all(name) as RigRow[];
|
||||
return rows.map((r) => this.rowToRig(r));
|
||||
}
|
||||
|
||||
getRigSummaries(filter?: RigArchiveFilter): Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null; hasServices: boolean; archivedAt: string | null }> {
|
||||
const cond = archiveWhereClause("r.archived_at", filter);
|
||||
const where = cond ? `WHERE ${cond}` : "";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -215,15 +215,23 @@ export class SeatDeliveryGuard {
|
||||
}
|
||||
|
||||
/** Current binding, never a latest historical session-name guess. Unbound seats
|
||||
* resolve by node/canonical address for preferences and lifecycle preflight. */
|
||||
* resolve by node/canonical address for preferences and lifecycle preflight.
|
||||
* #174: an archived rig can keep a binding to the same session name as a live
|
||||
* seat, so unarchived matches win; archived ones count only when nothing else
|
||||
* matches. Exactly one match is still required. */
|
||||
export function resolveGuardTarget(db: Database.Database, name: string): GuardTarget | null {
|
||||
const rows = db.prepare(`SELECT n.id AS nodeId,
|
||||
coalesce(b.tmux_session, replace(n.logical_id,'.','-') || '@' || r.name) AS session,
|
||||
b.tmux_pane AS pane,
|
||||
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant
|
||||
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant,
|
||||
r.archived_at AS archivedAt
|
||||
FROM nodes n JOIN rigs r ON r.id=n.rig_id LEFT JOIN bindings b ON b.node_id=n.id
|
||||
WHERE n.id=? OR b.tmux_session=? OR b.tmux_pane=? OR n.logical_id=?
|
||||
OR (b.tmux_session IS NULL AND replace(n.logical_id,'.','-') || '@' || r.name=?)`)
|
||||
.all(name, name, name, name, name) as GuardTarget[];
|
||||
return rows.length === 1 ? rows[0]! : null;
|
||||
.all(name, name, name, name, name) as Array<GuardTarget & { archivedAt: string | null }>;
|
||||
const unarchived = rows.filter((row) => row.archivedAt === null);
|
||||
const pool = unarchived.length > 0 ? unarchived : rows;
|
||||
if (pool.length !== 1) return null;
|
||||
const { archivedAt: _archivedAt, ...target } = pool[0]!;
|
||||
return target;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import type { JsonlExchange } from "./session-jsonl.js";
|
||||
import type { PersistedEvent } from "./types.js";
|
||||
import type { AppliedLaunchObservation } from "./permission-drift.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import { findOtherSessionOwner } from "./session-owner.js";
|
||||
|
||||
/** A bounded labeled-from-record recap of the predecessor's last exchanges + the record path,
|
||||
* resolved from the predecessor's provider transcript (claude transcript_path / codex rollout_path).
|
||||
@@ -654,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,
|
||||
@@ -890,25 +894,8 @@ export class SeatHandoverService {
|
||||
).get(nodeId) as SessionRow | undefined ?? null;
|
||||
}
|
||||
|
||||
private lookupManagedOwner(tmuxSession: string, targetNodeId: string): BindingOwnerRow | null {
|
||||
const bindingOwner = this.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 != ?
|
||||
LIMIT 1
|
||||
`).get(tmuxSession, targetNodeId) as BindingOwnerRow | undefined;
|
||||
if (bindingOwner) return bindingOwner;
|
||||
|
||||
return this.db.prepare(`
|
||||
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
|
||||
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')
|
||||
LIMIT 1
|
||||
`).get(tmuxSession, targetNodeId) as BindingOwnerRow | undefined ?? null;
|
||||
private lookupManagedOwner(tmuxSession: string, targetNodeId: string, ignoreArchived: boolean): BindingOwnerRow | null {
|
||||
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
|
||||
}
|
||||
|
||||
private commit(input: {
|
||||
|
||||
@@ -1100,7 +1100,7 @@ export class SeatLifecycleService {
|
||||
const parsed = parseSessionName(ref);
|
||||
if (parsed.kind === "canonical") {
|
||||
const localRef = parsed.member;
|
||||
const rigs = this.rigRepo.findRigsByName(parsed.rig);
|
||||
const rigs = this.rigRepo.findUnarchivedRigsByName(parsed.rig);
|
||||
return rigs.flatMap((rig) => getNodeInventory(this.db, rig.id).filter((entry) =>
|
||||
entry.canonicalSessionName === ref
|
||||
|| deriveCanonicalFromEntry(entry) === ref
|
||||
|
||||
@@ -82,7 +82,7 @@ export class SeatStatusService {
|
||||
if (parsed.kind === "canonical") {
|
||||
const localRef = parsed.member;
|
||||
const rigName = parsed.rig;
|
||||
const rigs = this.rigRepo.findRigsByName(rigName);
|
||||
const rigs = this.rigRepo.findUnarchivedRigsByName(rigName);
|
||||
return rigs.flatMap((rig) => this.entriesForRig(rig.id).filter((entry) =>
|
||||
entry.canonicalSessionName === ref || entry.logicalId === localRef
|
||||
).map((entry) => ({ entry })));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export interface SessionOwnerRow {
|
||||
node_id: string;
|
||||
logical_id: string;
|
||||
rig_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OTHER managed node that currently owns a tmux session name, if any: a node whose binding names
|
||||
* it, or a node with a live (not superseded, detached or exited) session row under it. A session name
|
||||
* 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; 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 ${notExcluded}${live}
|
||||
LIMIT 1
|
||||
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined;
|
||||
if (bindingOwner) return bindingOwner;
|
||||
|
||||
return db.prepare(`
|
||||
SELECT n.id AS node_id, n.logical_id, r.name AS rig_name
|
||||
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 ${notExcluded} AND s.status NOT IN ('superseded', 'detached', 'exited')${live}
|
||||
LIMIT 1
|
||||
`).get(tmuxSession, ...excluded) as SessionOwnerRow | undefined ?? null;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { wrapPaneEnvelope, appendDeliveredSegment, type EnvelopeScope } from "..
|
||||
import { getSelfHostId } from "./hosts/fanout-contract.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { verifyClaudePaneProcess, verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
|
||||
|
||||
@@ -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 Codex 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);
|
||||
@@ -1450,11 +1451,12 @@ export class SessionTransport {
|
||||
return null;
|
||||
}
|
||||
if (!paneCommand || !isShellForeground(paneCommand)) return null;
|
||||
if (runtime === "codex" && pane) {
|
||||
// Reuse the identity reconciler's stable, foreground, pane-descendant proof.
|
||||
// A resumed process must also name this session's token. Stale UI, a Node
|
||||
if ((runtime === "codex" || runtime === "claude-code") && pane) {
|
||||
// Reuse stable, foreground, pane-descendant proof. Claude fresh/resume and
|
||||
// Codex resume must name this session's token. Stale UI, a Node
|
||||
// launcher alone, missing observations or a native process elsewhere cannot clear it.
|
||||
const native = await verifyCodexPaneProcess({ target: sessionName, tmux: this.tmuxAdapter,
|
||||
const verify = runtime === "codex" ? verifyCodexPaneProcess : verifyClaudePaneProcess;
|
||||
const native = await verify({ target: sessionName, tmux: this.tmuxAdapter,
|
||||
listProcesses: this.listProcesses, expectedToken: resumeToken });
|
||||
if (native && await this.tmuxAdapter.getPanePid(pane).catch(() => null) === native.panePid) return null;
|
||||
}
|
||||
|
||||
@@ -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,141 @@
|
||||
// #174 — an archived duplicate of a live rig shares its name and its seats' canonical session names.
|
||||
// (b) `rig remove` on the archived rig must not kill, or route the queue work of, the live seat that owns
|
||||
// the shared session name. (a) Seat refs resolve against unarchived rigs only, so the archived duplicate
|
||||
// no longer makes the live seat ambiguous. Fake tmux only: no live session is touched.
|
||||
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 { 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 { SeatStatusService } from "../src/domain/seat-status-service.js";
|
||||
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
|
||||
import { resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const RIG = "bodies-in-motion";
|
||||
const SEAT = `lead-planner@${RIG}`;
|
||||
|
||||
describe("#174 archived duplicate rig vs the live seat with the same name", () => {
|
||||
let db: Database.Database;
|
||||
let rigRepo: RigRepository;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let eventBus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let killSession: ReturnType<typeof vi.fn>;
|
||||
let lifecycle: RigLifecycleService;
|
||||
let stale: { rigId: string; nodeId: string };
|
||||
let live: { rigId: string; nodeId: string; sessionId: string };
|
||||
|
||||
function seat(rigName: string, status: "running" | "exited", logicalId = "lead.planner", name = SEAT) {
|
||||
const rig = rigRepo.createRig(rigName);
|
||||
const node = rigRepo.addNode(rig.id, logicalId, { role: "planner", runtime: "claude-code" });
|
||||
const session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(session.id, status);
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name });
|
||||
return { rigId: rig.id, nodeId: node.id, sessionId: session.id };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
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 } as unknown as TmuxAdapter,
|
||||
});
|
||||
// The reported shape: the stale record was archived; the live rig re-created the same seat name.
|
||||
stale = seat(RIG, "exited");
|
||||
rigRepo.archiveRig(stale.rigId);
|
||||
live = seat(RIG, "running");
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("(b) removing the archived rig's node keeps the live seat's session and names its owner", async () => {
|
||||
const result = await lifecycle.removeNode(stale.rigId, "lead.planner");
|
||||
|
||||
expect(result).toMatchObject({ ok: true, sessionsKilled: 0, sessionKeptFor: `lead.planner@${RIG}` });
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
expect(rigRepo.getRig(stale.rigId)!.nodes).toHaveLength(0);
|
||||
const liveRow = db.prepare("SELECT status FROM sessions WHERE id = ?").get(live.sessionId) as { status: string };
|
||||
expect(liveRow.status).toBe("running");
|
||||
const liveBinding = db.prepare("SELECT tmux_session FROM bindings WHERE node_id = ?").get(live.nodeId) as { tmux_session: string };
|
||||
expect(liveBinding.tmux_session).toBe(SEAT);
|
||||
const detachedForName = db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'session.detached'").get() as { n: number };
|
||||
expect(detachedForName.n).toBe(0);
|
||||
});
|
||||
|
||||
it("(b) the live seat's open queue work neither blocks the removal nor moves", async () => {
|
||||
const work = await queueRepo.create({ sourceSession: `orch@${RIG}`, destinationSession: SEAT, body: "live seat's work" });
|
||||
|
||||
const result = await lifecycle.removeNode(stale.rigId, "lead.planner");
|
||||
|
||||
expect(result).toMatchObject({ ok: true, reroutedQitemIds: [] });
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
|
||||
});
|
||||
|
||||
it("(b) removing the LIVE node still kills its own session once; the archived twin's stale binding is not an owner", async () => {
|
||||
const result = await lifecycle.removeNode(live.rigId, "lead.planner");
|
||||
|
||||
expect(result).toMatchObject({ ok: true, sessionsKilled: 1 });
|
||||
expect(result.ok && "sessionKeptFor" in result).toBe(false);
|
||||
expect(killSession).toHaveBeenCalledExactlyOnceWith(SEAT);
|
||||
});
|
||||
|
||||
it("(b) removing the LIVE node still refuses its open work without --fallback", async () => {
|
||||
const work = await queueRepo.create({ sourceSession: `orch@${RIG}`, destinationSession: SEAT, body: "live seat's work" });
|
||||
|
||||
const result = await lifecycle.removeNode(live.rigId, "lead.planner");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "active_qitems" });
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
expect(queueRepo.getById(work.qitemId)).toMatchObject({ destinationSession: SEAT, state: "pending" });
|
||||
});
|
||||
|
||||
it("(guard) the delivery guard resolves the live seat despite the archived twin's binding", () => {
|
||||
expect(resolveGuardTarget(db, SEAT)).toMatchObject({ nodeId: live.nodeId, session: SEAT });
|
||||
});
|
||||
|
||||
it("(guard) control: two unarchived bindings to the same name still resolve to nothing", () => {
|
||||
seat(RIG, "running");
|
||||
expect(resolveGuardTarget(db, SEAT)).toBeNull();
|
||||
});
|
||||
|
||||
it("control: removing a node that owns its session still kills that session once", async () => {
|
||||
const own = seat("solo-rig", "running", "dev.impl", "dev-impl@solo-rig");
|
||||
const result = await lifecycle.removeNode(own.rigId, "dev.impl");
|
||||
|
||||
expect(result).toMatchObject({ ok: true, sessionsKilled: 1 });
|
||||
expect(result.ok && "sessionKeptFor" in result).toBe(false);
|
||||
expect(killSession).toHaveBeenCalledExactlyOnceWith("dev-impl@solo-rig");
|
||||
});
|
||||
|
||||
it("(a) seat status and handover resolution no longer see the archived duplicate", () => {
|
||||
const status = new SeatStatusService({ rigRepo }).getStatus(SEAT);
|
||||
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.ok && status.status.rig_id).toBe(live.rigId);
|
||||
});
|
||||
|
||||
it("(a) seat lifecycle verbs resolve the canonical ref to the live seat", async () => {
|
||||
const service = new SeatLifecycleService({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: { killSession } as unknown as TmuxAdapter });
|
||||
const result = await service.setModel({ seatRef: SEAT, model: "claude-fable-5", reason: "issue 174" });
|
||||
|
||||
expect(result.ok || result.code).not.toBe("seat_ambiguous");
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("control: an unarchived duplicate is still reported as ambiguous", () => {
|
||||
seat(RIG, "running", "lead.planner");
|
||||
const status = new SeatStatusService({ rigRepo }).getStatus(SEAT);
|
||||
|
||||
expect(status.ok).toBe(false);
|
||||
expect(!status.ok && status.code).toBe("seat_ambiguous");
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user