mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
3425e9d207
commit
1d26bcf24b
3 files changed
+194
-3
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user