diff --git a/src/gateway/server-methods/config.test.ts b/src/gateway/server-methods/config.test.ts index a62fa4bd521..7fe6a558c25 100644 --- a/src/gateway/server-methods/config.test.ts +++ b/src/gateway/server-methods/config.test.ts @@ -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", () => { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index bb6d8f4c7f8..20cec1f1b37 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -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, ); } diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 400436bfca8..8a4a28297b2 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -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, 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(); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index cb220a9b8bb..43014169840 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -501,9 +501,26 @@ export async function openConfigFile(state: ConfigState): Promise { 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 { // ignore } } + state.lastError = String(err); } }