fix(cli): explain seat identity misses on the reached daemon (#660)

When rig whoami or rig health --self reaches a daemon that does not know the seat's node or session, the not-found error now carries a hint naming the reached daemon and its URL, when known, and asks the user to compare OPENRIG_URL and OPENRIG_HOME in this shell with the daemon that launched the seat. The original error, status and exit code are unchanged. The hint makes one best-effort /healthz read on the same client with a one-second timeout, and omits URL credentials, query and fragment. Routing and environment propagation are unchanged.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-03 21:15:43 -07:00
1 parent 3425e9d207
commit 1d26bcf24b
3 files changed
+194 -3

No files matched your search

+4 -1
View File
@@ -19,7 +19,7 @@ import {
statusGuardMessage,
} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import { resolveIdentitySource } from "./whoami.js";
import { identityRoutingHint, resolveIdentitySource } from "./whoami.js";
import type { StatusDeps } from "./status.js";
import { shellQuote } from "../cross-host-executor.js";
import { omittedReadField, readView } from "../read-view.js";
@@ -100,6 +100,7 @@ interface HealthCliError {
message: string;
nextInspection: string;
details?: unknown;
hint?: string;
}
function defaultDeps(): HealthDeps {
@@ -124,6 +125,7 @@ function emitError(error: HealthCliError, json: boolean): void {
} else {
console.error(`Error: ${error.message}`);
console.error(` Next inspection: ${error.nextInspection}`);
if (error.hint) console.error(` ${error.hint}`);
}
process.exitCode = 1;
}
@@ -219,6 +221,7 @@ async function readSelfSeatId(client: DaemonClient, deps: HealthDeps, json: bool
message: "The daemon could not resolve the current seat identity.",
nextInspection: "rig whoami --json",
details: response.data,
...(response.status === 404 ? { hint: await identityRoutingHint(client) } : {}),
}, json);
return null;
}
+24 -2
View File
@@ -192,6 +192,26 @@ export function resolveIdentitySource(
return null;
}
/** Enrich an identity miss using only the client that returned it; never discover or retry another daemon. */
export async function identityRoutingHint(client: DaemonClient): Promise<string> {
let selfHostId: string | undefined;
try {
const health = await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 });
if (health.status === 200 && typeof health.data?.selfHostId === "string" && health.data.selfHostId.trim()) {
selfHostId = health.data.selfHostId;
}
} catch { /* A failed identity read must not replace the original error. */ }
let location = "";
try {
const url = new URL(client.baseUrl);
url.username = ""; url.password = ""; url.search = ""; url.hash = "";
location = ` at ${JSON.stringify(url.href)}`;
} catch { /* Do not echo an unparseable URL that may contain credentials. */ }
const daemon = selfHostId ? `daemon ${JSON.stringify(selfHostId)}` : "daemon (host ID unavailable)";
return `Reached ${daemon}${location}. Seat routing may point to a different OpenRig install. `
+ "Compare OPENRIG_URL and OPENRIG_HOME in this shell with the daemon that launched this seat.";
}
export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
const cmd = new Command("whoami").description("Show current managed identity in an OpenRig topology");
const getDeps = (): WhoamiDeps => depsOverride ?? {
@@ -265,11 +285,12 @@ workspace block). The compact form omits the Context line; use 'rig context' or
if (!full) params.set("compact", "1");
const res = await client.get<Record<string, unknown>>(`/api/whoami?${params.toString()}`);
const hint = res.status === 404 ? await identityRoutingHint(client) : undefined;
if (opts.json) {
if (full || res.status >= 400) {
// --full: today's complete payload (parity). Errors: pass through.
console.log(JSON.stringify(res.data, null, 2));
// Preserve the complete payload and original error; the routing hint is additive.
console.log(JSON.stringify(hint ? { ...res.data, hint } : res.data, null, 2));
} else {
// Compact default: the identity-recovery ALLOWLIST projection.
console.log(JSON.stringify(projectCompactWhoami(res.data)));
@@ -281,6 +302,7 @@ workspace block). The compact form omits the Context line; use 'rig context' or
if (res.status === 404) {
const error = (res.data as Record<string, unknown>)["error"] as string | undefined;
console.error(error ?? "Session not found in any managed rig. Check: rig ps --nodes");
if (hint) console.error(hint);
process.exitCode = 1;
return;
}
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { DaemonClient, DaemonTimeoutError } from "../src/client.js";
import { whoamiCommand, type WhoamiDeps } from "../src/commands/whoami.js";
import { healthCommand } from "../src/commands/health.js";
vi.mock("../src/daemon-lifecycle.js", async () => ({
...await vi.importActual("../src/daemon-lifecycle.js"),
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://selected-daemon:7433"),
}));
const original = { error: "Session or node 'missing-node' not found in any managed rig. Check available sessions with: rig ps --nodes" };
const hint = 'Reached daemon "selected-host" at "http://selected-daemon:7433/". '
+ "Seat routing may point to a different OpenRig install. "
+ "Compare OPENRIG_URL and OPENRIG_HOME in this shell with the daemon that launched this seat.";
let out: string[], err: string[];
beforeEach(() => {
vi.stubEnv("OPENRIG_NODE_ID", "missing-node");
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig");
vi.stubEnv("OPENRIG_ACTIVITY_HOOK_TOKEN", "must-not-print-token");
out = []; err = [];
vi.spyOn(console, "log").mockImplementation((...args) => out.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args) => err.push(args.join(" ")));
process.exitCode = undefined;
});
afterEach(() => {
process.exitCode = undefined;
vi.useRealTimers(); vi.unstubAllEnvs(); vi.restoreAllMocks();
});
function setup(status = 404, data: Record<string, unknown> = original,
identity: unknown = { status: 200, data: { selfHostId: "selected-host" } },
baseUrl = "http://selected-daemon:7433") {
const get = vi.fn(async (path: string, _options?: unknown) => {
if (path === "/healthz") {
if (identity instanceof Error) throw identity;
return identity;
}
return { status, data };
});
const deps: WhoamiDeps = {
lifecycleDeps: {} as WhoamiDeps["lifecycleDeps"],
clientFactory: () => ({ baseUrl, get }) as unknown as DaemonClient,
};
return { deps, get };
}
async function run(command: "whoami" | "health", deps: WhoamiDeps, args: string[]) {
const cmd = command === "whoami" ? whoamiCommand(deps)
: healthCommand({ ...deps, resolveIdentity: () => ({ nodeId: "missing-node" }) });
await cmd.parseAsync(["node", "rig", ...args]);
}
it.each([{ args: [] }, { args: ["--json"] }, { args: ["--json", "--full"] }])("whoami unknown node adds routing guidance (%j)", async ({ args }) => {
const { deps, get } = setup();
await run("whoami", deps, args);
if (args.includes("--json")) expect(JSON.parse(out.join("\n"))).toEqual({ ...original, hint });
else expect(err).toEqual([original.error, hint]);
expect(process.exitCode).toBe(1);
expect(get.mock.calls).toEqual([
[`/api/whoami?nodeId=missing-node${args.includes("--full") ? "" : "&compact=1"}`],
["/healthz", { timeoutMs: 1_000 }],
]);
expect(original).not.toHaveProperty("hint");
expect([...out, ...err].join("\n")).not.toContain("must-not-print-token");
});
it("whoami unknown session uses the same hint without switching to a node lookup", async () => {
vi.stubEnv("OPENRIG_NODE_ID", "");
const { deps, get } = setup();
await run("whoami", deps, ["--json"]);
expect(JSON.parse(out.join("\n"))).toEqual({ ...original, hint });
expect(get.mock.calls[0]).toEqual(["/api/whoami?sessionName=seat%40rig&compact=1"]);
expect(get).toHaveBeenCalledTimes(2);
expect(process.exitCode).toBe(1);
});
it.each([false, true])("health self keeps its error envelope and shows routing guidance (json=%s)", async (json) => {
const { deps, get } = setup();
await run("health", deps, json ? ["--json"] : []);
if (json) expect(JSON.parse(out.join("\n"))).toEqual({
schema: "openrig.health-error/v0alpha1", error: "health_identity_unavailable",
message: "The daemon could not resolve the current seat identity.",
nextInspection: "rig whoami --json", details: original, hint,
});
else expect(err).toEqual([
"Error: The daemon could not resolve the current seat identity.",
" Next inspection: rig whoami --json", ` ${hint}`,
]);
expect(process.exitCode).toBe(1);
expect(get.mock.calls).toEqual([
["/api/whoami?nodeId=missing-node&compact=1"], ["/healthz", { timeoutMs: 1_000 }],
]);
});
it.each([
new DaemonTimeoutError("private transport detail"),
{ status: 200, data: {} },
{ status: 503, data: { selfHostId: "unconfirmed-host" } },
])("identity enrichment failure keeps the original error and useful routing advice", async (identity) => {
const { deps, get } = setup(404, original, identity);
await run("whoami", deps, ["--json"]);
const body = JSON.parse(out.join("\n"));
expect(body.error).toBe(original.error);
expect(body.hint).toBe(hint.replace('daemon "selected-host"', "daemon (host ID unavailable)"));
expect(process.exitCode).toBe(1);
expect(get).toHaveBeenCalledTimes(2);
});
it.each([400, 401, 409, 500])("other identity errors (%s) stay unchanged, with no extra lookup", async (status) => {
for (const command of ["whoami", "health"] as const) {
const { deps, get } = setup(status, { error: "unchanged" });
out = []; err = [];
await run(command, deps, ["--json"]);
const body = JSON.parse(out.join("\n"));
expect(body).not.toHaveProperty("hint");
if (command === "whoami") expect(body).toEqual({ error: "unchanged" });
else expect(body.details).toEqual({ error: "unchanged" });
expect(process.exitCode).toBe(1);
expect(get).toHaveBeenCalledTimes(1);
}
});
it("full identity success remains byte-equivalent and makes no enrichment read", async () => {
const data = { identity: { nodeId: "known" }, peers: [], extra: "unchanged" };
const { deps, get } = setup(200, data);
await run("whoami", deps, ["--full", "--json"]);
expect(out).toEqual([JSON.stringify(data, null, 2)]);
expect(get).toHaveBeenCalledTimes(1);
expect(process.exitCode).toBeUndefined();
});
it("omits URL credentials, query and fragment from the additive hint", async () => {
const { deps } = setup(404, original, undefined,
"https://private-user:private-password@selected-daemon:7433/proxy?token=private-query#private-fragment");
await run("whoami", deps, ["--json"]);
const body = JSON.parse(out.join("\n"));
expect(body.hint).toBe(hint.replace("http://selected-daemon:7433/", "https://selected-daemon:7433/proxy"));
expect(body.error).toBe(original.error);
});
it("bounds a hanging identity response with the actual DaemonClient timeout", async () => {
vi.useFakeTimers();
let started!: () => void;
const healthStarted = new Promise<void>((resolve) => { started = resolve; });
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
if (!String(url).endsWith("/healthz")) return new Response(JSON.stringify(original), { status: 404 });
started();
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
});
});
const client = new DaemonClient("http://selected-daemon:7433", { fetchImpl: fetchImpl as typeof fetch });
const { deps } = setup();
deps.clientFactory = () => client;
const pending = run("whoami", deps, ["--json"]);
await healthStarted;
await vi.advanceTimersByTimeAsync(1_001);
await pending;
const body = JSON.parse(out.join("\n"));
expect(body.error).toBe(original.error);
expect(body.hint).toContain("host ID unavailable");
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(process.exitCode).toBe(1);
});