Skip to content

Commit f385491

Browse files
authored
fix: clarify gateway SecretRef auth diagnostics (#92290)
* fix gateway secretref health diagnostics * fix gateway health result type narrowing
1 parent 7387083 commit f385491

11 files changed

Lines changed: 267 additions & 37 deletions

extensions/browser/src/cli/browser-cli-manage.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,4 +461,24 @@ describe("browser manage output", () => {
461461
expect(output).toContain("OK gateway: browser control endpoint reachable");
462462
expect(output).toContain("OK tabs: 1 visible, use tab reference t1");
463463
});
464+
465+
it("prints a readable browser doctor failure when gateway auth SecretRefs are unavailable", async () => {
466+
const error = Object.assign(new Error("gateway.auth.password unavailable"), {
467+
code: "GATEWAY_SECRET_REF_UNAVAILABLE",
468+
name: "GatewaySecretRefUnavailableError",
469+
});
470+
getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error);
471+
472+
const program = createBrowserManageProgram();
473+
await expect(program.parseAsync(["browser", "doctor"], { from: "user" })).rejects.toThrow(
474+
"__exit__:1",
475+
);
476+
477+
const output = lastRuntimeLog();
478+
expect(output).toContain(
479+
"FAIL gateway: Gateway auth SecretRef is unavailable in this command path",
480+
);
481+
expect(output).toContain("OPENCLAW_GATEWAY_TOKEN");
482+
expect(output).not.toContain("GatewaySecretRefUnavailableError");
483+
});
464484
});

extensions/browser/src/cli/browser-cli-manage.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,24 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
152152
return `${check.ok ? "OK" : "FAIL"} ${check.name}${check.detail ? `: ${check.detail}` : ""}`;
153153
}
154154

155+
function isGatewaySecretRefUnavailableErrorShape(error: unknown): boolean {
156+
if (!(error instanceof Error)) {
157+
return false;
158+
}
159+
const errorRecord = error as Error & { code?: unknown };
160+
return (
161+
errorRecord.name === "GatewaySecretRefUnavailableError" ||
162+
errorRecord.code === "GATEWAY_SECRET_REF_UNAVAILABLE"
163+
);
164+
}
165+
166+
function formatBrowserDoctorGatewayError(error: unknown): string {
167+
if (!isGatewaySecretRefUnavailableErrorShape(error)) {
168+
return String(error);
169+
}
170+
return "Gateway auth SecretRef is unavailable in this command path; browser doctor cannot reach the admin-scoped browser.request endpoint. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD, then retry.";
171+
}
172+
155173
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
156174
const checks: BrowserDoctorCheck[] = [];
157175
let status: BrowserStatus | null;
@@ -167,7 +185,7 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee
167185
checks.push({
168186
name: "gateway",
169187
ok: false,
170-
detail: String(err),
188+
detail: formatBrowserDoctorGatewayError(err),
171189
});
172190
return { ok: false, checks };
173191
}

src/cli/gateway-cli.coverage.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,18 @@ const { runtimeLogs, runtimeErrors, defaultRuntime } = mocks;
4545
vi.mock(
4646
new URL("../../gateway/call.ts", new URL("./gateway-cli/call.ts", import.meta.url)).href,
4747
() => ({
48+
buildGatewayConnectionDetails: () => ({
49+
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
50+
url: "ws://127.0.0.1:18789",
51+
}),
52+
buildGatewayProbeConnectionDetails: () => ({
53+
preauthHandshakeTimeoutMs: 1000,
54+
tlsFingerprint: undefined,
55+
url: "ws://127.0.0.1:18789",
56+
}),
4857
callGateway: (opts: unknown) => callGateway(opts),
4958
formatGatewayTransportErrorJson: (error: unknown) => formatGatewayTransportErrorJson(error),
59+
isGatewayCredentialsRequiredError: () => false,
5060
randomIdempotencyKey: () => "rk_test",
5161
}),
5262
);

src/cli/gateway-cli/register.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,19 @@ function parseDaysOption(raw: unknown, fallback = 30): number {
140140
return fallback;
141141
}
142142

143+
function parseGatewayRpcTimeoutOption(raw: unknown, fallback = 10_000): number {
144+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
145+
return Math.floor(raw);
146+
}
147+
if (typeof raw === "string" && raw.trim() !== "") {
148+
const parsed = parseStrictPositiveInteger(raw);
149+
if (parsed !== undefined) {
150+
return parsed;
151+
}
152+
}
153+
return fallback;
154+
}
155+
143156
function resolveGatewayRpcOptions<T extends { token?: string; password?: string }>(
144157
opts: T,
145158
command?: Command,
@@ -534,17 +547,36 @@ export function registerGatewayCli(program: Command) {
534547
await runGatewayCommand(
535548
async () => {
536549
const rpcOpts = resolveGatewayRpcOptions(opts, command);
537-
const [{ formatHealthChannelLines }, { styleHealthChannelLine }] = await Promise.all([
538-
loadGatewayHealthModule(),
539-
loadHealthStyleModule(),
540-
]);
541-
const result = await callGatewayCli("health", rpcOpts);
550+
const [
551+
{ emitReachableGatewayAuthDiagnostic, formatHealthChannelLines },
552+
{ styleHealthChannelLine },
553+
] = await Promise.all([loadGatewayHealthModule(), loadHealthStyleModule()]);
554+
let result: unknown;
555+
try {
556+
result = await callGatewayCli("health", rpcOpts);
557+
} catch (error) {
558+
const { readBestEffortConfig } = await loadConfigModule();
559+
const handled = await emitReachableGatewayAuthDiagnostic({
560+
error,
561+
config: await readBestEffortConfig(),
562+
runtime: defaultRuntime,
563+
timeoutMs: parseGatewayRpcTimeoutOption(rpcOpts.timeout),
564+
token: rpcOpts.token,
565+
password: rpcOpts.password,
566+
json: Boolean(rpcOpts.json),
567+
});
568+
if (handled) {
569+
return;
570+
}
571+
throw error;
572+
}
542573
if (rpcOpts.json) {
543574
defaultRuntime.writeJson(result);
544575
return;
545576
}
546577
const rich = isRich();
547-
const obj: Record<string, unknown> = result && typeof result === "object" ? result : {};
578+
const obj: Record<string, unknown> =
579+
result && typeof result === "object" ? (result as Record<string, unknown>) : {};
548580
const durationMs = typeof obj.durationMs === "number" ? obj.durationMs : null;
549581
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
550582
defaultRuntime.log(

src/commands/channels.status.command-flow.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Channels status command-flow tests cover gateway calls, config fallback, and timeout validation.
22
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js";
34
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
45
import { channelsStatusCommand } from "./channels/status.js";
56
import { createCapturingTestRuntime } from "./test-runtime-config-helpers.js";
@@ -267,6 +268,29 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
267268
expect(joined).toContain("token:config (unavailable)");
268269
});
269270

271+
it("labels config-only fallback as auth-unavailable when gateway auth SecretRefs are unresolved", async () => {
272+
mocks.callGateway.mockRejectedValue(
273+
new GatewaySecretRefUnavailableError("gateway.auth.password"),
274+
);
275+
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
276+
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
277+
resolvedConfig: { secretResolved: false, channels: {} },
278+
effectiveConfig: { secretResolved: false, channels: {} },
279+
diagnostics: [],
280+
});
281+
const { runtime, logs, errors } = createCapturingTestRuntime();
282+
283+
await channelsStatusCommand({ probe: false }, runtime as never);
284+
285+
const errorOutput = errors.join("\n");
286+
expect(errorOutput).toContain("Gateway auth unavailable");
287+
expect(errorOutput).not.toContain("Gateway not reachable");
288+
const joined = logs.join("\n");
289+
expect(joined).toContain("Gateway auth unavailable; showing config-only status.");
290+
expect(joined).not.toContain("Gateway not reachable; showing config-only status.");
291+
expect(joined).toContain("configured, secret unavailable in this command path");
292+
});
293+
270294
it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => {
271295
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
272296
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
@@ -349,6 +373,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
349373
expect(payload.error).not.toContain("fallback-user:fallback-pass");
350374
expect(payload.error).not.toContain("fallback-secret");
351375
expect(payload.gatewayReachable).toBe(false);
376+
expect(payload.gatewayAuthUnavailable).toBe(false);
352377
expect(payload.configOnly).toBe(true);
353378
expect(payload.configuredChannels).toStrictEqual([]);
354379
});

src/commands/channels/status-config-format.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ type ChannelStatusPluginLabel = {
3636
export async function formatConfigChannelsStatusLines(
3737
cfg: OpenClawConfig,
3838
meta: { path?: string; mode?: "local" | "remote" },
39-
opts?: { sourceConfig?: OpenClawConfig; channel?: string },
39+
opts?: { sourceConfig?: OpenClawConfig; channel?: string; fallbackReason?: string },
4040
): Promise<string[]> {
4141
const lines: string[] = [];
42-
lines.push(theme.warn("Gateway not reachable; showing config-only status."));
42+
lines.push(
43+
theme.warn(opts?.fallbackReason ?? "Gateway not reachable; showing config-only status."),
44+
);
4345
if (meta.path) {
4446
lines.push(`Config: ${meta.path}`);
4547
}

src/commands/channels/status.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js";
1010
import { withProgress } from "../../cli/progress.js";
1111
import { readConfigFileSnapshot } from "../../config/config.js";
1212
import { callGateway } from "../../gateway/call.js";
13+
import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js";
1314
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
1415
import { formatErrorMessage } from "../../infra/errors.js";
1516
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
@@ -213,7 +214,7 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
213214
return lines;
214215
}
215216

216-
/** Query gateway channel status, falling back to config-only output when unreachable. */
217+
/** Query gateway channel status, falling back to config-only output when unavailable. */
217218
export async function channelsStatusCommand(
218219
opts: ChannelsStatusOptions,
219220
runtime: RuntimeEnv = defaultRuntime,
@@ -256,7 +257,13 @@ export async function channelsStatusCommand(
256257
runtime.log(formatGatewayChannelsStatusLines(payload).join("\n"));
257258
} catch (err) {
258259
const safeError = formatChannelsStatusError(err);
259-
runtime.error(`Gateway not reachable: ${safeError}`);
260+
const gatewayAuthUnavailable = isGatewaySecretRefUnavailableError(err);
261+
const fallbackReason = gatewayAuthUnavailable
262+
? "Gateway auth unavailable; showing config-only status."
263+
: "Gateway not reachable; showing config-only status.";
264+
runtime.error(
265+
`${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`,
266+
);
260267
const cfg = await requireValidConfigSnapshot(runtime);
261268
if (!cfg) {
262269
return;
@@ -274,6 +281,7 @@ export async function channelsStatusCommand(
274281
writeRuntimeJson(runtime, {
275282
gatewayReachable: false,
276283
error: safeError,
284+
gatewayAuthUnavailable,
277285
configOnly: true,
278286
config: {
279287
path: snapshot.path,
@@ -296,7 +304,7 @@ export async function channelsStatusCommand(
296304
path: snapshot.path,
297305
mode,
298306
},
299-
{ sourceConfig: cfg, channel: opts.channel },
307+
{ sourceConfig: cfg, channel: opts.channel, fallbackReason },
300308
)
301309
).join("\n"),
302310
);

src/commands/doctor-gateway-health.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88

99
const callGateway = vi.hoisted(() => vi.fn());
1010
const isGatewayCredentialsRequiredError = vi.hoisted(() => vi.fn(() => false));
11+
const isGatewaySecretRefUnavailableError = vi.hoisted(() => vi.fn(() => false));
1112
const probeGatewayStatus = vi.hoisted(() => vi.fn());
1213
const note = vi.hoisted(() => vi.fn());
1314
const TEST_GATEWAY_URL = "ws://127.0.0.1:18789";
@@ -28,6 +29,10 @@ vi.mock("../gateway/call.js", () => ({
2829
isGatewayCredentialsRequiredError,
2930
}));
3031

32+
vi.mock("../gateway/credentials.js", () => ({
33+
isGatewaySecretRefUnavailableError,
34+
}));
35+
3136
vi.mock("../cli/daemon-cli/probe.js", () => ({
3237
probeGatewayStatus,
3338
}));
@@ -49,6 +54,8 @@ describe("checkGatewayHealth", () => {
4954
callGateway.mockReset();
5055
isGatewayCredentialsRequiredError.mockReset();
5156
isGatewayCredentialsRequiredError.mockReturnValue(false);
57+
isGatewaySecretRefUnavailableError.mockReset();
58+
isGatewaySecretRefUnavailableError.mockReturnValue(false);
5259
probeGatewayStatus.mockReset();
5360
note.mockReset();
5461
});
@@ -143,6 +150,38 @@ describe("checkGatewayHealth", () => {
143150
);
144151
expect(callGateway).toHaveBeenCalledTimes(1);
145152
});
153+
154+
it("reports credentials-required when status RPC auth SecretRefs are unavailable", async () => {
155+
const error = new Error("gateway.auth.password unavailable");
156+
callGateway.mockRejectedValueOnce(error);
157+
isGatewaySecretRefUnavailableError.mockReturnValueOnce(true);
158+
probeGatewayStatus.mockResolvedValueOnce({
159+
ok: false,
160+
kind: "connect",
161+
error: TEST_AUTH_CLOSE_ERROR,
162+
});
163+
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
164+
165+
await expect(
166+
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
167+
).resolves.toEqual({ authenticated: false, healthOk: true });
168+
169+
expect(isGatewaySecretRefUnavailableError).toHaveBeenCalledWith(error);
170+
expect(probeGatewayStatus).toHaveBeenCalledWith({
171+
url: TEST_GATEWAY_URL,
172+
timeoutMs: 3000,
173+
tlsFingerprint: TEST_TLS_FINGERPRINT,
174+
preauthHandshakeTimeoutMs: 4321,
175+
config: cfg,
176+
json: true,
177+
});
178+
expect(runtime.error).not.toHaveBeenCalled();
179+
expect(note).toHaveBeenCalledWith(
180+
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
181+
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
182+
);
183+
expect(callGateway).toHaveBeenCalledTimes(1);
184+
});
146185
});
147186

148187
describe("probeGatewayMemoryStatus", () => {

src/commands/doctor-gateway-health.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
callGateway,
99
isGatewayCredentialsRequiredError,
1010
} from "../gateway/call.js";
11+
import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
1112
import type { DoctorMemoryStatusPayload } from "../gateway/server-methods/doctor.js";
1213
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
1314
import { formatErrorMessage } from "../infra/errors.js";
@@ -38,6 +39,10 @@ function isGatewayCallTimeout(message: string): boolean {
3839
return /^gateway timeout after \d+ms(?:\n|$)/.test(message);
3940
}
4041

42+
function isGatewayHealthAuthUnavailableError(error: unknown): boolean {
43+
return isGatewayCredentialsRequiredError(error) || isGatewaySecretRefUnavailableError(error);
44+
}
45+
4146
function noteCliGatewayVersionSkew(status: StatusSummary | undefined): void {
4247
const gatewayVersion = status?.runtimeVersion?.trim();
4348
if (!gatewayVersion || gatewayVersion === VERSION) {
@@ -102,7 +107,7 @@ export async function checkGatewayHealth(params: {
102107
}
103108
return { healthOk, authenticated: true, status };
104109
} catch (err) {
105-
if (isGatewayCredentialsRequiredError(err)) {
110+
if (isGatewayHealthAuthUnavailableError(err)) {
106111
const probeDetails = await buildGatewayProbeConnectionDetails({ config: params.cfg });
107112
const probe = await probeGatewayStatus({
108113
url: probeDetails.url,

0 commit comments

Comments
 (0)