Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/launcher/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,22 @@ export const test = base.extend<LauncherFixtures>({
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<string, string>
Expand Down
46 changes: 41 additions & 5 deletions packages/launcher/e2e/respond.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 })
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion packages/launcher/src/main/agents/daemon-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,26 @@ export function startDaemon(connector: Record<string, unknown> | 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,11 +448,15 @@ export function ConfigureDialog({
onValueChange={(v) => setAuthTab(v as "cli" | "key")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="cli" className="text-xs">
{/* 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. */}
<TabsTrigger value="cli" className="text-xs" data-testid="auth-tab-cli">
<Terminal />
{t("agents.list.health.cliLogin")}
</TabsTrigger>
<TabsTrigger value="key" className="text-xs">
<TabsTrigger value="key" className="text-xs" data-testid="auth-tab-key">
<KeyRound />
{t("agents.list.health.apiKey")}
</TabsTrigger>
Expand Down
Loading