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")}