From 1890de2a764ad643a84a7adeae5472b5626ab1b4 Mon Sep 17 00:00:00 2001 From: lixiang <1014027506@qq.com> Date: Tue, 8 Sep 2026 15:12:33 +0800 Subject: [PATCH] fix(e2e): fill the API key on the tab that actually holds it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit respond went 0/6 on the first run to reach the LLM call. Reading the daemon.yaml each agent process was handed: openclaw/opencode LLM_API_KEY + LLM_BASE_URL= + LLM_MODEL ok codex OPENAI_BASE_URL=api.openai.com, no key no claude ANTHROPIC_BASE_URL=api.anthropic.com, no key no gemini GOOGLE_GEMINI_BASE_URL=, no key no Those three are the dual-auth agents, and their Configure dialog opens on the CLI sign-in tab. Radix unmounts the tab that is not selected, so the key form is not in the DOM at all — the spec enumerates `agent-config-*`, finds only the CLI tab's model fields, and fills no key. Save then writes every field's stored default regardless of the tab, which is where the official base URLs came from, and the key field is optional on an agent that could equally be signed in, so nothing objected: the dialog closed, connect passed, and the agent started with no credential. So the spec now selects the API-key tab before it enumerates, and fails loudly when no `*_API_KEY` input was filled. Silently saving an empty key is what made this look like a gateway or model problem for two rounds. Also here, all from the same run's evidence: - Attachments are redacted. daemon.yaml holds the instance env verbatim, so the raw file put the gateway key and the workspace token into artifacts that outlive the run. - The isolated HOME now sets HOMEDRIVE/HOMEPATH as well as USERPROFILE. openclaw's auth store was found under the real profile during a run whose HOME was a temp dir, and a tool reading the older pair is the shape of that. - The daemon's exit log says how long the process lived, and words a clean stop apart from a failure. "exited early" was asserted, not measured: the handler outlives the spawn, so an intentional stop minutes in printed the same alarming line as a spawn that died on the spot, and every daemon.log carried one. Product side is two data-testid attributes on the auth tabs and the daemon log wording. No behaviour change, so no version bump. --- packages/launcher/e2e/fixtures.ts | 12 +++++ packages/launcher/e2e/respond.spec.ts | 46 +++++++++++++++++-- .../src/main/agents/daemon-process.ts | 16 ++++++- .../agents/components/configure-dialog.tsx | 8 +++- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/launcher/e2e/fixtures.ts b/packages/launcher/e2e/fixtures.ts index 5e51fc3a6..74cac6e3a 100644 --- a/packages/launcher/e2e/fixtures.ts +++ b/packages/launcher/e2e/fixtures.ts @@ -158,10 +158,22 @@ export const test = base.extend({ const localAppData = path.join(homeDir, "AppData", "Local") mkdirSync(appData, { recursive: true }) mkdirSync(localAppData, { recursive: true }) + // HOMEDRIVE + HOMEPATH as well as USERPROFILE: a Windows tool that resolves + // the home directory from the older pair lands in the REAL profile whatever + // USERPROFILE says, and the isolation is only as good as its leakiest + // reader. openclaw's auth store was found under C:\Users\Administrator + // during a run whose HOME was a temp dir, which is the shape of exactly + // this. Windows-only: the pair means nothing elsewhere, and homeDir is + // always drive-lettered there (mkdtemp under %TEMP%). + const winHome = + process.platform === "win32" && /^[A-Za-z]:/.test(homeDir) + ? { HOMEDRIVE: homeDir.slice(0, 2), HOMEPATH: homeDir.slice(2) } + : {} const env = { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + ...winHome, APPDATA: appData, LOCALAPPDATA: localAppData, } as Record diff --git a/packages/launcher/e2e/respond.spec.ts b/packages/launcher/e2e/respond.spec.ts index f4dbebff0..0e2f8201f 100644 --- a/packages/launcher/e2e/respond.spec.ts +++ b/packages/launcher/e2e/respond.spec.ts @@ -248,9 +248,10 @@ test.describe("launcher full flow", () => { await page.getByTestId("new-agent-create").click() // 3. Configure LLM — the dialog auto-opens after create. Agents with GUI key - // fields (openclaw/opencode/codex/gemini) get filled + Saved. Agents with - // no key field (claude no-config; cursor/hermes login-only) get their env - // injected via IPC, then the dialog is closed (→ Connect dialog opens). + // fields get filled + Saved: openclaw/opencode show one plain form, while + // the dual-auth ones (claude/codex/gemini) put it behind the API-key tab. + // Agents with no key field at all (cursor/hermes, login-only) get their + // env injected via IPC, then the dialog is closed (→ Connect opens). const save = page.getByTestId("cfg-save") // getEnvFields (IPC → core) can be slow right after install, esp. on Windows. const hasKeyFields = await save @@ -264,19 +265,40 @@ test.describe("launcher full flow", () => { if (hasKeyFields) { await expect(async () => { if (await connectDialog.isVisible().catch(() => false)) return + // Dual-auth agents (claude/codex/gemini) open on the CLI sign-in tab, + // and Radix unmounts the tab that isn't selected — so the key form is + // not in the DOM to be found, and the only `agent-config-*` inputs + // here are the CLI tab's model fields. Save writes every field's + // default whatever the tab, so this used to save an EMPTY key and the + // provider's stock base URL and still close the dialog: connect passed + // and the agent then started with no credential at all. + const keyTab = page.getByTestId("auth-tab-key") + if (await keyTab.isVisible().catch(() => false)) await keyTab.click() + const fieldIds = await page.evaluate(() => Array.from(document.querySelectorAll('[id^="agent-config-"]')).map( (e) => e.id, ), ) + let filledKey = false for (const id of fieldIds) { const varName = id.replace("agent-config-", "") let val: string | undefined if (varName.endsWith("_API_KEY")) val = cred.key else if (varName.endsWith("_BASE_URL")) val = cred.base else if (varName.endsWith("_MODEL")) val = cred.model - if (val) await page.locator(`[id="${id}"]`).fill(val) + if (!val) continue + await page.locator(`[id="${id}"]`).fill(val) + if (varName.endsWith("_API_KEY")) filledKey = true } + // Saving without a key is not a failure the dialog reports — the field + // is optional for an agent that could also be signed in — so assert it + // here. Without this the run reaches the reply check with nothing to + // authenticate, and reads as a gateway or model problem. + if (!filledKey) + throw new Error( + `no *_API_KEY input found for ${SLUG}; saw [${fieldIds.join(", ")}]`, + ) await save.click().catch(() => {}) await expect(connectDialog).toBeVisible({ timeout: 6_000 }) }).toPass({ timeout: 60_000 }) @@ -354,12 +376,26 @@ test.describe("launcher full flow", () => { } catch (e) { // Attach the daemon log/status so a non-reply is diagnosable (why the // agent didn't answer: LLM error, join failure, wrong model, etc.). + // + // Redacted rather than attached as-is: daemon.yaml holds the instance env + // verbatim, so the raw file would put the gateway key and the workspace + // token into artifacts that outlive the run and get copied around. Only + // the values this test knows are secret are masked — everything else is + // left alone so the attachment stays diagnosable. const fs = await import("node:fs") const p = await import("node:path") + const secrets = [cred.key, process.env.E2E_WS_TOKEN].filter( + (v): v is string => !!v, + ) + const redact = (text: string): string => + secrets.reduce((acc, v) => acc.split(v).join("***REDACTED***"), text) for (const rel of ["daemon.log", "daemon.status.json", "daemon.yaml"]) { const fp = p.join(homeDir, ".openagents", rel) if (fs.existsSync(fp)) { - await test.info().attach(rel, { path: fp }) + await test.info().attach(rel, { + body: redact(fs.readFileSync(fp, "utf8")), + contentType: "text/plain", + }) } } throw e diff --git a/packages/launcher/src/main/agents/daemon-process.ts b/packages/launcher/src/main/agents/daemon-process.ts index 08c709184..5d0f1f36d 100644 --- a/packages/launcher/src/main/agents/daemon-process.ts +++ b/packages/launcher/src/main/agents/daemon-process.ts @@ -266,12 +266,26 @@ export function startDaemon(connector: Record | null): { env: withPathEnv(enhancedPath, daemonEnv), windowsHide: true, }) + const spawnedAt = Date.now() proc.once("error", (err: Error) => { appendDaemonLog(`daemon spawn error: ${err.message}`) }) proc.once("exit", (code: number | null, signal: NodeJS.Signals | null) => { + // "early" used to be asserted rather than measured: this handler lives as + // long as the process, so a deliberate stop minutes in logged the very + // same line as a spawn that died on the spot. Every daemon.log therefore + // carried an alarming-looking exit that nothing could date. Print the age + // instead and let the reader tell the two apart. + const how = + `after ${Date.now() - spawnedAt}ms: ` + + `code=${code ?? "null"} signal=${signal ?? "null"}` + // Word the two cases apart: the Logs page classifies a line as an error + // by its text, so a clean stop must not read like a crash — and a + // non-zero exit still has to. appendDaemonLog( - `daemon process exited early: code=${code ?? "null"} signal=${signal ?? "null"}`, + code + ? `daemon process failed and exited ${how}` + : `daemon process exited ${how}`, ) }) proc.unref() diff --git a/packages/launcher/src/renderer/pages/agents/components/configure-dialog.tsx b/packages/launcher/src/renderer/pages/agents/components/configure-dialog.tsx index 62f8d6939..e8e2a9c17 100644 --- a/packages/launcher/src/renderer/pages/agents/components/configure-dialog.tsx +++ b/packages/launcher/src/renderer/pages/agents/components/configure-dialog.tsx @@ -448,11 +448,15 @@ export function ConfigureDialog({ onValueChange={(v) => setAuthTab(v as "cli" | "key")} > - + {/* Handles, not labels: the tab names are translated, and the + key form only exists in the DOM while its tab is the + selected one — the e2e matrix has to be able to select + it. */} + {t("agents.list.health.cliLogin")} - + {t("agents.list.health.apiKey")}