fix(ui): show config open failure feedback (#87108)

Fixes #87020.

Summary:
- Surface config.openFile failures in the Control UI instead of silently doing nothing.
- Return actionable gateway errors for headless opener failures, including the config path.
- Add gateway and UI controller regression coverage for the failed-open path.

Verification:
- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-methods.config.ts src/gateway/server-methods/config.test.ts --reporter=dot
- node scripts/run-vitest.mjs run --config test/vitest/vitest.ui.config.ts ui/src/ui/controllers/config.test.ts --reporter=dot
- pnpm check:changed via Blacksmith Testbox tbx_01ksktydqx6mk3n20yevcbkwtn
- autoreview --mode local

Thanks @Linux2010.

Co-authored-by: Linux2010 <35169750+Linux2010@users.noreply.github.com>
This commit is contained in:
Andy Tien
2026-05-27 12:45:45 +08:00
committed by GitHub
parent 59818226a9
commit 8246e91e92
4 changed files with 116 additions and 7 deletions

View File

@@ -100,7 +100,7 @@ describe("config.openFile", () => {
);
});
it("returns a generic error and logs details when the opener fails", async () => {
it("returns a detailed error and logs details when the opener fails", async () => {
process.env.OPENCLAW_CONFIG_PATH = "/tmp/config.json";
execFileMock.mockImplementation((...args: unknown[]) => {
invokeExecFileCallback(
@@ -120,7 +120,7 @@ describe("config.openFile", () => {
{
ok: false,
path: "/tmp/config.json",
error: "failed to open config file",
error: "Failed to open config file: spawn xdg-open ENOENT",
},
undefined,
);
@@ -128,6 +128,36 @@ describe("config.openFile", () => {
"config.openFile failed path=/tmp/config.json: spawn xdg-open ENOENT",
);
});
it("returns actionable headless environment error when xdg-open reports no method available", async () => {
process.env.OPENCLAW_CONFIG_PATH = "/tmp/config.json";
execFileMock.mockImplementation((...args: unknown[]) => {
invokeExecFileCallback(
args,
new Error("xdg-open: no method available for opening '/tmp/config.json'"),
);
return {} as never;
});
const { options, respond, logGateway } = createConfigHandlerHarness({
method: "config.openFile",
});
await configHandlers["config.openFile"](options);
expect(respond).toHaveBeenCalledWith(
true,
{
ok: false,
path: "/tmp/config.json",
error:
"Cannot open file in headless environment. File path: /tmp/config.json. This environment appears to lack a graphical or terminal browser handler.",
},
undefined,
);
expect(logGateway.warn).toHaveBeenCalledWith(
"config.openFile failed path=/tmp/config.json: xdg-open: no method available for opening '/tmp/config.json'",
);
});
});
describe("config schema response cache", () => {

View File

@@ -702,12 +702,17 @@ export const configHandlers: GatewayRequestHandlers = {
await execConfigOpenCommand(resolveConfigOpenCommand(configPath));
respond(true, { ok: true, path: configPath }, undefined);
} catch (error) {
const errorMessage = formatConfigOpenError(error);
const isHeadlessError = errorMessage.includes("xdg-open") && errorMessage.includes("no method available");
const detailedError = isHeadlessError
? `Cannot open file in headless environment. File path: ${configPath}. This environment appears to lack a graphical or terminal browser handler.`
: `Failed to open config file: ${errorMessage}`;
context?.logGateway?.warn(
`config.openFile failed path=${sanitizeLookupPathForLog(configPath)}: ${formatConfigOpenError(error)}`,
`config.openFile failed path=${sanitizeLookupPathForLog(configPath)}: ${errorMessage}`,
);
respond(
true,
{ ok: false, path: configPath, error: "failed to open config file" },
{ ok: false, path: configPath, error: detailedError },
undefined,
);
}

View File

@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyConfigSnapshot,
applyConfig,
ensureAgentConfigEntry,
findAgentConfigEntryIndex,
loadConfig,
openConfigFile,
resetConfigPendingChanges,
runUpdate,
saveConfig,
@@ -64,6 +65,10 @@ function requireRequestCall(request: ReturnType<typeof vi.fn>, index = 0): unkno
return call;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("applyConfigSnapshot", () => {
it("does not clobber form edits while dirty", () => {
const state = createState();
@@ -271,6 +276,57 @@ describe("loadConfig", () => {
});
});
describe("openConfigFile", () => {
it("surfaces failed open responses and copies the returned config path", async () => {
const request = vi.fn().mockResolvedValue({
ok: false,
path: "/tmp/openclaw.json",
error: "Cannot open file in headless environment.",
});
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.lastError = "stale error";
await openConfigFile(state);
expect(request).toHaveBeenCalledWith("config.openFile", {});
expect(writeText).toHaveBeenCalledWith("/tmp/openclaw.json");
expect(state.lastError).toBe(
"Cannot open file in headless environment.\n\nFile path copied to clipboard: /tmp/openclaw.json",
);
});
it("includes the config path in the visible error when clipboard fallback fails", async () => {
const request = vi.fn().mockResolvedValue({
ok: false,
error: "Failed to open config file",
});
const writeText = vi.fn().mockRejectedValue(new Error("clipboard denied"));
vi.stubGlobal("navigator", { clipboard: { writeText } });
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.configSnapshot = {
config: {},
path: "/tmp/from-snapshot.json",
valid: true,
issues: [],
};
await openConfigFile(state);
expect(writeText).toHaveBeenCalledWith("/tmp/from-snapshot.json");
expect(state.lastError).toBe(
"Failed to open config file\n\nFile path: /tmp/from-snapshot.json",
);
});
});
describe("updateConfigFormValue", () => {
it("seeds from snapshot when form is null", () => {
const state = createState();

View File

@@ -501,9 +501,26 @@ export async function openConfigFile(state: ConfigState): Promise<void> {
if (!state.client || !state.connected) {
return;
}
state.lastError = null;
try {
await state.client.request("config.openFile", {});
} catch {
const res = await state.client.request<{ ok: boolean; path?: string; error?: string }>(
"config.openFile",
{},
);
if (!res.ok) {
const errorMessage = res.error || "Failed to open config file";
state.lastError = errorMessage;
const path = res.path || state.configSnapshot?.path;
if (path) {
try {
await navigator.clipboard.writeText(path);
state.lastError += `\n\nFile path copied to clipboard: ${path}`;
} catch {
state.lastError += `\n\nFile path: ${path}`;
}
}
}
} catch (err) {
const path = state.configSnapshot?.path;
if (path) {
try {
@@ -512,5 +529,6 @@ export async function openConfigFile(state: ConfigState): Promise<void> {
// ignore
}
}
state.lastError = String(err);
}
}