mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c648f43dca | ||
|
|
85e302e7b6 | ||
|
|
7ba503523f |
No files matched your search
@@ -76,8 +76,17 @@ You need Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude
|
||||
Code or Codex account: one is enough, since the kernel operator adapts the
|
||||
shipped `starter` team to whichever you have. On Linux, the distribution's own
|
||||
Node.js can be older (Ubuntu 24.04's is 18); install a supported version with
|
||||
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac
|
||||
with Apple silicon, use Node.js 22 (see the [compatibility
|
||||
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource.
|
||||
|
||||
For a tarball or manual Node install on Linux, put its bin directory on PATH in
|
||||
`~/.profile`, for example `export PATH="/path/to/node/bin:$PATH"`, rather than only
|
||||
in an interactive `~/.bashrc`. Bash reads the first of `~/.bash_profile`,
|
||||
`~/.bash_login` and `~/.profile`; if one of the first two exists, source `~/.profile`
|
||||
there. Other shells use their own login file (for example, zsh's `~/.zprofile`).
|
||||
Check `bash -lc 'command -v rig node'`: both commands must resolve for Codex's
|
||||
login-shell tools to use them.
|
||||
|
||||
On a Mac with Apple silicon, use Node.js 22 (see the [compatibility
|
||||
history](../releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where
|
||||
you aren't an administrator, or where Homebrew belongs to another account (its
|
||||
`brew install` is refused), install Node.js in your own home folder with
|
||||
|
||||
@@ -21,9 +21,12 @@ export function runtimeVersionProbeCwd(cmd: string): string | undefined {
|
||||
* runtime binary otherwise holds a rig launch (or the daemon's startup) indefinitely and outlives the daemon. */
|
||||
const versionProbe = boundedProbeExec(undefined, undefined, { cwd: runtimeVersionProbeCwd });
|
||||
|
||||
/** Login profiles can hang too; keep this lookup on the existing bounded probe path. */
|
||||
export const RIG_LOGIN_PATH_CHECK = " -lc 'command -v rig'";
|
||||
|
||||
/** Production preflight only; callers still prefer an explicitly injected exec. */
|
||||
export const execPreflightCommand: ExecFn = async (cmd) => {
|
||||
if (runtimeVersionProbeCwd(cmd) === undefined) return execCommand(cmd);
|
||||
if (runtimeVersionProbeCwd(cmd) === undefined && !cmd.endsWith(RIG_LOGIN_PATH_CHECK)) return execCommand(cmd);
|
||||
return versionProbe(cmd);
|
||||
};
|
||||
|
||||
|
||||
@@ -1445,7 +1445,7 @@ export class PodRigInstantiator {
|
||||
if (!nameGuard.ok) return nameGuard;
|
||||
|
||||
// 2. Preflight
|
||||
const preflight = await rigPreflight({ rigSpecYaml, rigRoot, cwdOverride: opts?.cwdOverride, fsOps: this.deps.fsOps, skillsRoot: this.resolveSkillsRoot(), ...this.systemWorldResolutionContext(), exec: this.deps.exec, claudeActivityAssets: this.deps.claudeActivityAssets });
|
||||
const preflight = await rigPreflight({ rigSpecYaml, rigRoot, cwdOverride: opts?.cwdOverride, envOverride: opts?.env, fsOps: this.deps.fsOps, skillsRoot: this.resolveSkillsRoot(), ...this.systemWorldResolutionContext(), exec: this.deps.exec, claudeActivityAssets: this.deps.claudeActivityAssets });
|
||||
if (!preflight.ready) {
|
||||
return { ok: false, code: "preflight_failed", errors: preflight.errors, warnings: preflight.warnings };
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ import type Database from "better-sqlite3";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import type { ExecFn } from "../adapters/tmux.js";
|
||||
import { runtimeProbeFailure, runtimeProbeTimeout } from "../adapters/preflight-exec.js";
|
||||
import { RIG_LOGIN_PATH_CHECK, runtimeProbeFailure, runtimeProbeTimeout } from "../adapters/preflight-exec.js";
|
||||
import { piVersionNotice } from "../adapters/pi-readiness.js";
|
||||
import type { LegacyRigSpec as RigSpec, PreflightResult, RigSpec as PodRigSpec, RigSpecPod, RigSpecPodMember } from "./types.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
import { deriveCanonicalSessionName, deriveSessionName, tmuxRenamedSessionError, validateSessionName, validateSessionComponents, VIRTUAL_DOMAIN_TOKENS } from "./session-name.js";
|
||||
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import { mergeSeatEnv, type SeatEnv } from "./seat-env.js";
|
||||
import { withoutRuntimeHomes } from "./seat-runtime-home.js";
|
||||
|
||||
const RUNTIME_COMMANDS: Record<string, string> = {
|
||||
"claude-code": "claude --version",
|
||||
"codex": "codex --version",
|
||||
@@ -116,6 +120,10 @@ export class RigSpecPreflight {
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
await warnRigLoginPath(spec.nodes.filter(node => node.runtime !== "terminal").map(node => ({ label: node.id, env: {} })), this.exec, warnings);
|
||||
}
|
||||
|
||||
// cmux layout hints: warning if cmux unavailable and spec uses hints
|
||||
const hasLayoutHints = spec.nodes.some((n) => n.surfaceHint || n.workspace);
|
||||
if (hasLayoutHints) {
|
||||
@@ -165,6 +173,8 @@ const DEFAULT_ACTIVITY_RELAY_PATH = nodePath.resolve(import.meta.dirname, "../..
|
||||
const DEFAULT_CLAUDE_HOOKS_MANIFEST_PATH = nodePath.resolve(import.meta.dirname, "../../assets/plugins/openrig-core/hooks/claude.json");
|
||||
|
||||
export interface RigPreflightInput {
|
||||
/** One-off launch environment, merged after the rig and member declarations. */
|
||||
envOverride?: SeatEnv;
|
||||
rigSpecYaml: string;
|
||||
rigRoot: string;
|
||||
cwdOverride?: string;
|
||||
@@ -185,6 +195,7 @@ export interface RigPreflightInput {
|
||||
* already-parsed+validated spec. (YAML is only the input adapter at the front.)
|
||||
*/
|
||||
export interface PreflightSpecContext {
|
||||
envOverride?: SeatEnv;
|
||||
rigRoot: string;
|
||||
cwdOverride?: string;
|
||||
fsOps: AgentResolverFsOps;
|
||||
@@ -275,6 +286,7 @@ export async function rigPreflight(input: RigPreflightInput & { exec?: (cmd: str
|
||||
return preflightValidatedSpec(rigSpec, {
|
||||
rigRoot: input.rigRoot,
|
||||
cwdOverride: input.cwdOverride,
|
||||
envOverride: input.envOverride,
|
||||
fsOps: input.fsOps,
|
||||
skillsRoot: input.skillsRoot,
|
||||
rigNameOverride: input.rigNameOverride,
|
||||
@@ -308,6 +320,7 @@ async function preflightMembers(
|
||||
resolveNodeConfigAsync: (context: ResolutionContext) => Promise<ResolutionResult>,
|
||||
): Promise<PreflightResult> {
|
||||
const errors: string[] = [];
|
||||
const loginTargets: Array<{ label: string; env: SeatEnv }> = [];
|
||||
// §6 reconciliation (PM ruling 2026-08-05): seed EMPTY so main's already-folded activity-hook /
|
||||
// collision warnings (pushed below during the checks) come FIRST; the incoming permission-policy
|
||||
// discovery warnings are APPENDED at the return. Rationale + semantic fence at the return site.
|
||||
@@ -390,6 +403,9 @@ async function preflightMembers(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (member.runtime !== "terminal") loginTargets.push({ label: `${pod.id}.${member.id}`,
|
||||
env: mergeSeatEnv(rigSpec.env, member.env, preflightCtx.envOverride && withoutRuntimeHomes(preflightCtx.envOverride)) });
|
||||
|
||||
// Managed Claude activity-hook delivery preflight (NONFATAL): when a claude-code member
|
||||
// selects the claude_activity_hooks resource, verify the daemon can actually deliver
|
||||
// (relay asset present + canonical manifest yields >= 1 relay event) using the SHARED
|
||||
@@ -440,6 +456,7 @@ async function preflightMembers(
|
||||
const piErrors = await verifyPiRuntimeAvailable(rigSpec, preflightCtx.exec, warnings);
|
||||
errors.push(...piErrors);
|
||||
errors.push(...await verifyOmpRuntimeAvailable(rigSpec, preflightCtx.exec));
|
||||
if (errors.length === 0 && !preflightCtx.materializeOnly) await warnRigLoginPath(loginTargets, preflightCtx.exec, warnings);
|
||||
}
|
||||
|
||||
// §6 RECONCILIATION — WARNING EMISSION ORDER (PM ruling 2026-08-05): ACTIVITY-HOOK-FIRST,
|
||||
@@ -457,6 +474,25 @@ async function preflightMembers(
|
||||
return { ready: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
/** A login-shell lookup for each distinct declared seat environment, advisory only.
|
||||
* No profile output or environment values are included in the warning. */
|
||||
async function warnRigLoginPath(targets: Array<{ label: string; env: SeatEnv }>, exec: ExecFn, warnings: string[]): Promise<void> {
|
||||
const checks = new Map<string, string[]>();
|
||||
for (const { label, env } of targets) {
|
||||
const shell = env.SHELL || process.env.SHELL || "/bin/sh";
|
||||
const assignments = Object.keys(env).sort().map(key => shellQuote(`${key}=${env[key]}`));
|
||||
const command = `/usr/bin/env ${[...assignments, shellQuote(shell)].join(" ")}${RIG_LOGIN_PATH_CHECK}`;
|
||||
const labels = checks.get(command) ?? [];
|
||||
labels.push(label); checks.set(command, labels);
|
||||
}
|
||||
for (const [command, labels] of checks) {
|
||||
try { await exec(command); }
|
||||
catch {
|
||||
warnings.push(`${labels.join(", ")}: login-shell PATH check could not confirm 'rig'; launch continues. For tarball/manual Node, put its bin directory on PATH in ~/.profile (or the login file your shell reads), then check: bash -lc 'command -v rig node'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.PI1 FR-1 — async post-preflight probe: when the spec declares any
|
||||
* `runtime: "pi"` member, verify the `pi` binary answers `pi --version`.
|
||||
|
||||
@@ -167,7 +167,10 @@ describe("RigSpecPreflight", () => {
|
||||
});
|
||||
|
||||
it("a Pi version check that hit the time limit stays a warning with the same message", async () => {
|
||||
const exec = vi.fn<ExecFn>().mockRejectedValue(new ProbeTimeoutError("pi --version", RUNTIME_PROBE_TIMEOUT_MS));
|
||||
const exec = vi.fn<ExecFn>().mockImplementation(async command => {
|
||||
if (command === "pi --version") throw new ProbeTimeoutError(command, RUNTIME_PROBE_TIMEOUT_MS);
|
||||
return "/usr/bin/rig";
|
||||
});
|
||||
const result = await createPreflight({ exec }).check(validSpec({ nodes: [{ id: "worker", runtime: "pi", cwd: "/" }] }));
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
@@ -559,3 +562,97 @@ profiles:
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("seat login-shell PATH warning", () => {
|
||||
const isLookup = (command: string) => command.endsWith(" -lc 'command -v rig'");
|
||||
const warning = (warnings: string[]) => warnings.filter(text => text.includes("login-shell PATH"));
|
||||
async function preflight(exec: ExecFn, materializeOnly = false, runtime = "codex") {
|
||||
const member = { agentRef: "local:agents/impl", profile: "default", runtime, cwd: ".",
|
||||
env: { PATH: "/member/bin:/usr/bin", MEMBER_TAG: "member" } };
|
||||
const input = {
|
||||
rigSpecYaml: makeRigYaml({ env: { SHELL: "/bin/bash", PATH: "/rig/bin:/usr/bin", RIG_TAG: "rig" },
|
||||
pods: [{ id: "dev", label: "Dev", members: [{ ...member, id: "one" }, { ...member, id: "two" }], edges: [] }] }),
|
||||
rigRoot: RIG_ROOT, fsOps: mockFs({ [`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl") }),
|
||||
exec, materializeOnly, envOverride: { PATH: "/override/bin:/usr/bin" },
|
||||
};
|
||||
return rigPreflight(input);
|
||||
}
|
||||
|
||||
it("warns once for matching seat environments without refusing, with the login-profile fix", async () => {
|
||||
const exec = vi.fn<ExecFn>().mockImplementation(async command => {
|
||||
if (isLookup(command)) throw Object.assign(new Error("private diagnostic"), { code: 1 });
|
||||
return "available";
|
||||
});
|
||||
const result = await preflight(exec);
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(warning(result.warnings)).toHaveLength(1);
|
||||
expect(warning(result.warnings)[0]).toContain("dev.one, dev.two");
|
||||
expect(warning(result.warnings)[0]).toContain("~/.profile");
|
||||
expect(warning(result.warnings)[0]).toContain("bash -lc 'command -v rig node'");
|
||||
expect(warning(result.warnings)[0]).toContain("launch continues");
|
||||
expect(result.warnings.join("\n")).not.toContain("private diagnostic");
|
||||
const lookups = exec.mock.calls.map(([command]) => command).filter(isLookup);
|
||||
expect(lookups).toHaveLength(1);
|
||||
expect(lookups[0]).toContain("'/bin/bash' -lc 'command -v rig'");
|
||||
expect(lookups[0]).toContain("'PATH=/override/bin:/usr/bin'");
|
||||
expect(lookups[0]).toContain("'RIG_TAG=rig'");
|
||||
expect(lookups[0]).toContain("'MEMBER_TAG=member'");
|
||||
expect(lookups[0]).not.toContain("/rig/bin");
|
||||
expect(lookups[0]).not.toContain("/member/bin");
|
||||
});
|
||||
|
||||
it("reports an uncompleted login check as unconfirmed, with no new error or leaked command", async () => {
|
||||
const exec = vi.fn<ExecFn>().mockImplementation(async command => {
|
||||
if (isLookup(command)) throw new ProbeTimeoutError(command + " private diagnostic", RUNTIME_PROBE_TIMEOUT_MS);
|
||||
return "available";
|
||||
});
|
||||
const result = await preflight(exec);
|
||||
expect(result).toMatchObject({ ready: true, errors: [] });
|
||||
expect(warning(result.warnings)).toHaveLength(1);
|
||||
expect(warning(result.warnings)[0]).toContain("could not confirm");
|
||||
expect(warning(result.warnings)[0]).not.toContain("private diagnostic");
|
||||
expect(warning(result.warnings)[0]).not.toContain("/override/bin");
|
||||
});
|
||||
|
||||
it("keeps a successful lookup quiet", async () => {
|
||||
const result = await preflight(async () => "/opt/node/bin/rig");
|
||||
expect(result).toMatchObject({ ready: true, errors: [] });
|
||||
expect(warning(result.warnings)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not start a login-shell lookup for materialize-only preflight", async () => {
|
||||
const exec = vi.fn<ExecFn>().mockResolvedValue("available");
|
||||
const result = await preflight(exec, true);
|
||||
expect(result).toMatchObject({ ready: true, errors: [] });
|
||||
expect(exec.mock.calls.map(([command]) => command).filter(isLookup)).toEqual([]);
|
||||
expect(warning(result.warnings)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a Claude-only pod quiet when rig exists only in its interactive shell", async () => {
|
||||
const exec = vi.fn<ExecFn>().mockImplementation(async command => {
|
||||
if (isLookup(command)) throw Object.assign(new Error("login PATH has no rig"), { code: 1 });
|
||||
return "available";
|
||||
});
|
||||
const result = await preflight(exec, false, "claude-code");
|
||||
expect(result).toMatchObject({ ready: true, errors: [] });
|
||||
expect(warning(result.warnings)).toEqual([]);
|
||||
expect(exec.mock.calls.map(([command]) => command).filter(isLookup)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a Claude-only legacy launch quiet without a login-shell lookup", async () => {
|
||||
const db = setupDb();
|
||||
const exec = vi.fn<ExecFn>().mockImplementation(async command => {
|
||||
if (isLookup(command)) throw Object.assign(new Error("login PATH has no rig"), { code: 1 });
|
||||
return "available";
|
||||
});
|
||||
try {
|
||||
const preflight = new RigSpecPreflight({ rigRepo: new RigRepository(db), tmuxAdapter: mockTmux(), exec, cmuxExec: exec });
|
||||
const result = await preflight.check(validSpec());
|
||||
expect(result).toMatchObject({ ready: true, errors: [] });
|
||||
expect(warning(result.warnings)).toEqual([]);
|
||||
expect(exec.mock.calls.map(([command]) => command).filter(isLookup)).toEqual([]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user