From caf551cc814d27bb73c6e75dd1f018ef93410d02 Mon Sep 17 00:00:00 2001 From: lixiang <1014027506@qq.com> Date: Sun, 6 Sep 2026 19:06:55 +0800 Subject: [PATCH] fix(launcher): make CLI sign-in and installs work off the machine's real environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four failures with one shape: something the launcher spawns could not see the environment it needed, and the check that should have caught it looked somewhere else and passed. CodeBuddy signs into the wrong site. The CLI fronts two separate services (codebuddy.ai / codebuddy.cn) and keeps ONE session on disk, chosen by CODEBUDDY_INTERNET_ENVIRONMENT. The sign-in terminal opened bare, so it always signed in against the international site while an agent set to the China site ran with the variable pinned to `internal` and failed auth on every message. The terminal (and the per-agent Chat terminal) now carry the same value the adapter overlays on the agent's own runs. ...and the sign-in could never be confirmed. codebuddy had no probe at all, so verifyLogin was false however the login went and the poll ran out after five minutes. It joins DUAL_LOGIN_AGENTS with a creds-file probe reading the session file `/login` writes, in the CLI's extension data dir on all three platforms. A new `credsGuard` hook checks the session's own domain against the configured region — a session for the other site would otherwise read as a green Ready in front of an agent that cannot authenticate. Only `auth`/`auth.domain` are read; the token in the same file is never touched. The sign-in terminal went direct while the browser used the proxy, failing with "Client network socket disconnected before secure TLS connection was established" on a machine whose browser had just reported the login as successful. On macOS the terminal is not our child — osascript hands a script to the already-running Terminal.app — so it never saw the proxy this process had resolved. The same gap `adoptSystemProxyForChildren` documents for `kimi login`, which was only ever closed for the in-app piped path; the terminal-only CLIs (codebuddy, gemini, hermes) were the ones still exposed. The terminal script now carries it, with the user's own bypass list kept and 127.0.0.1 (where these sign-ins complete) off the tunnel. Installs could not find a user-installed tool. OpenWorker installs with `uv tool install`, uv lands in ~/.local/bin, and a GUI process inherits a shell-less PATH. install-preflight's probeUv searches ~/.local/bin and passed; installer.js's _buildShellEnv did not add it and the spawn died with exit 127. Both sides now agree: the launcher restores the user bin dirs on its own PATH at startup (appended, so anything the user configured still wins) and the core adds them as a last-resort fallback. Also fixed while in here: - runTerminal's Linux branch dropped extraEnv entirely, so Gemini's workspace trust and CodeBuddy's site pinning never reached a Linux terminal. - commandcode's probe read `whoami`, which reports an unreachable account service as an ordinary error on a CLEAN exit — indistinguishable from success under negative-only matching. It now reads `status`, the CLI's own auth check, with both directions named so an offline machine stays "unknown". - CODEBUDDY_AUTH_TOKEN counts as configured credentials (registry lists it beside the API key, but it is not an *_API_KEY). - OpenWorker's keyless providers (ollama, openai-codex) no longer read "No model API key" forever; openai-codex is labelled as the CLI sign-in it is. - _savedTypeEnvForProbe reads the connector's raw agent list, not getAgents() — that path derives health, health asks the probe, and the probe lands back there. Launcher 0.9.27 with release notes. 476 launcher tests, 1512 core tests. --- packages/agent-connector/registry.json | 2 +- packages/agent-connector/src/installer.js | 16 +++ packages/launcher/changelog/0.9.27.json | 61 +++++++++ packages/launcher/package.json | 2 +- packages/launcher/src/main/agent-manager.ts | 58 +++++++- .../src/main/agents/auth-specs.test.ts | 91 ++++++++++++- .../launcher/src/main/agents/auth-specs.ts | 126 ++++++++++++++++-- .../src/main/agents/codebuddy-signin.test.ts | 94 +++++++++++++ .../src/main/agents/codebuddy-signin.ts | 123 +++++++++++++++++ .../launcher/src/main/agents/health.test.ts | 95 +++++++++++++ packages/launcher/src/main/agents/health.ts | 48 ++++++- .../src/main/agents/login-probe.test.ts | 76 +++++++++++ .../launcher/src/main/agents/login-probe.ts | 23 +++- .../src/main/bootstrap/node-runtime.ts | 56 ++++++++ .../src/main/bootstrap/user-path.test.ts | 62 +++++++++ packages/launcher/src/main/index.ts | 49 +++++-- packages/launcher/src/main/net-config.test.ts | 49 ++++++- packages/launcher/src/main/net-config.ts | 37 +++++ registry/codebuddy.json | 2 +- workspace/backend/registry/codebuddy.json | 2 +- 20 files changed, 1028 insertions(+), 44 deletions(-) create mode 100644 packages/launcher/changelog/0.9.27.json create mode 100644 packages/launcher/src/main/agents/codebuddy-signin.test.ts create mode 100644 packages/launcher/src/main/agents/codebuddy-signin.ts create mode 100644 packages/launcher/src/main/agents/health.test.ts create mode 100644 packages/launcher/src/main/bootstrap/user-path.test.ts diff --git a/packages/agent-connector/registry.json b/packages/agent-connector/registry.json index acd71570a..aba58c0b2 100644 --- a/packages/agent-connector/registry.json +++ b/packages/agent-connector/registry.json @@ -1680,7 +1680,7 @@ "saved_env_key": "CODEBUDDY_API_KEY", "login_command": "codebuddy", "unverifiable": true, - "not_ready_message": "Sign-in not confirmed. Set CODEBUDDY_API_KEY (press e), or run `codebuddy` and sign in with /login — there is no non-interactive login command. Auth is confirmed when you run a task.", + "not_ready_message": "Not signed in for this site. Sign in with the Login button — it opens the CLI on the site CODEBUDDY_REGION selects, where you run /login — or set CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN (press e). A session for the other site does not carry over: the CLI keeps only one, and codebuddy.ai and codebuddy.cn are separate accounts.", "auth_detected_labels": { "cli_login": "CodeBuddy sign-in detected", "api_key": "API key detected" diff --git a/packages/agent-connector/src/installer.js b/packages/agent-connector/src/installer.js index 6864491b1..74409062b 100644 --- a/packages/agent-connector/src/installer.js +++ b/packages/agent-connector/src/installer.js @@ -1805,6 +1805,22 @@ class Installer { } catch {} } } + // Where a user-level installer puts things, on every platform. uv's own + // `curl … | sh` (and its Windows script) install into ~/.local/bin and add + // it to PATH through a shell profile that a GUI-launched process never + // reads — so an install command that IS `uv tool install …` died with + // "uv: command not found" on a machine that had uv. install-preflight's + // probeUv already searches these two, so leaving them out here meant the + // pre-flight passed and the spawn it was guarding then failed. + // unshift, not push: the loop below PREPENDS each entry, so the first one + // here ends up LAST on PATH. These are a fallback for a binary nothing else + // provides — they must never shadow the user's own PATH or the runtime + // directories above. + extraDirs.unshift( + path.join(os.homedir(), '.local', 'bin'), + path.join(os.homedir(), '.cargo', 'bin'), + ); + // On Windows the spread above yields a "Path" key, not "PATH". Writing // `env.PATH` would create a duplicate key holding only extraDirs and drop // everything else; libuv then resolves spawned binaries against the wrong, diff --git a/packages/launcher/changelog/0.9.27.json b/packages/launcher/changelog/0.9.27.json new file mode 100644 index 000000000..6ec64fecc --- /dev/null +++ b/packages/launcher/changelog/0.9.27.json @@ -0,0 +1,61 @@ +{ + "version": "0.9.27", + "date": "2026-09-06", + "entries": [ + { + "type": "fix", + "title": { + "en": "CodeBuddy sign-in now lands on the site you chose", + "zh": "CodeBuddy 登录现在会落在你所选择的站点上" + }, + "description": { + "en": "CodeBuddy Code fronts two separate services — codebuddy.ai / workbuddy.ai and codebuddy.cn / workbuddy.cn — and keeps only one sign-in on your machine. The launcher used to open the sign-in terminal with nothing to say which site it was for, so it always signed in against the international one. If you had picked the China site, the terminal reported a successful login and every message the agent then sent failed to authenticate, with nothing on screen to explain why. The Login button now opens the CLI on the site the agent's Region setting names, and the launcher reads the sign-in back: a completed login turns the agent Ready on its own, and a session belonging to the other site is reported as such instead of showing a green Ready in front of an agent that cannot run. If you signed in before this release, sign in once more from the Login button. An API key or platform token remains the alternative, and a saved CODEBUDDY_AUTH_TOKEN now counts as configured credentials rather than reading as \"login required\".", + "zh": "CodeBuddy Code 面向两套彼此独立的服务 —— codebuddy.ai / workbuddy.ai 与 codebuddy.cn / workbuddy.cn —— 而本机只会保留一份登录态。此前启动器打开登录终端时并未告知它应当面向哪个站点,因此始终登录到国际站。若你选择的是中国站,终端会提示登录成功,而 Agent 随后发出的每一条消息都会认证失败,界面上却没有任何说明。现在「登录」按钮会按该 Agent 的站点设置打开命令行,并由启动器读回登录结果:登录完成后 Agent 会自行变为就绪;若本机的登录态属于另一站点,则会如实指出,而不是在一个根本无法运行的 Agent 前显示绿色就绪。若你在本次更新之前已登录过,请从「登录」按钮重新登录一次。API 密钥与平台令牌仍是可选的另一条路径,且已保存的 CODEBUDDY_AUTH_TOKEN 现在会被正确视为已配置凭据,不再显示为「需要登录」。" + } + }, + { + "type": "fix", + "title": { + "en": "A sign-in terminal now goes through your proxy", + "zh": "登录终端现在会走你的代理" + }, + "description": { + "en": "Signing in from a terminal could fail with \"Client network socket disconnected before secure TLS connection was established\" on the very machine whose browser had just reported the login as successful. The two halves were taking different routes: the browser went through the proxy the machine is configured with, while the CLI — running in a terminal window the launcher does not own — went direct and was cut off mid-handshake. The launcher now writes the proxy it resolved into the terminal it opens, with your own bypass list preserved and the local address the sign-in completes on kept off the tunnel. On Linux this also restores the per-agent sign-in settings, which that platform's terminal had been dropping entirely.", + "zh": "从终端登录时,可能出现「Client network socket disconnected before secure TLS connection was established」,而同一台机器上浏览器那一侧刚刚提示登录成功。原因是两半走了不同的线路:浏览器经由本机配置的代理,而命令行运行在启动器并不拥有的终端窗口中,直连出网并在握手中途被切断。现在启动器会把已解析出的代理写入它所打开的终端,同时保留你自己的绕行列表,并确保登录回调所用的本机地址不被代理。在 Linux 上,这项改动还顺带修复了该平台终端此前完全丢弃各 Agent 登录设置的问题。" + } + }, + { + "type": "fix", + "title": { + "en": "Command Code sign-in is read from its own status command", + "zh": "Command Code 的登录状态改由其自身的状态命令判定" + }, + "description": { + "en": "The launcher asked `command-code whoami` whether you were signed in. That command reports an unreachable account service as an ordinary error on a successful exit, which the launcher could only read as \"signed in\" — so a machine that was merely offline looked authenticated. It now reads `command-code status`, the CLI's own authentication check, which distinguishes signed in from signed out from cannot-tell. An offline machine is left at cannot-tell and the agent keeps working; a genuinely signed-out one is now said so plainly.", + "zh": "此前启动器通过 `command-code whoami` 判断登录状态。当账号服务不可达时,该命令会以正常退出码输出一条普通错误,启动器只能将其读作「已登录」,于是仅仅是断网的机器也会显示为已认证。现在改为读取 `command-code status`,即该命令行自带的认证检查,它能够区分已登录、未登录与无法判定三种情形。断网时判定为无法判定,Agent 照常可用;真正未登录时则会明确指出。" + } + }, + { + "type": "fix", + "title": { + "en": "OpenWorker no longer asks for a key it does not need", + "zh": "OpenWorker 不再索要它并不需要的密钥" + }, + "description": { + "en": "Two of OpenWorker's providers need no API key: a local Ollama server, and reusing an existing ChatGPT sign-in from a state directory. Both were judged on the key anyway, so an agent that ran perfectly well stayed badged \"No model API key\" indefinitely. Either choice now reads as configured, and a reused ChatGPT sign-in is labelled as the sign-in it is.", + "zh": "OpenWorker 有两种服务商无需 API 密钥:本地 Ollama 服务,以及复用状态目录中已有的 ChatGPT 登录。此前二者仍按密钥来判定,因此一个运行完全正常的 Agent 会长期显示「缺少模型 API 密钥」。现在这两种选择都会被正确视为已配置,复用的 ChatGPT 登录也会被标注为登录方式而非密钥。" + } + }, + { + "type": "fix", + "title": { + "en": "Installs can find the tools you installed yourself", + "zh": "安装时能找到你自己装的工具" + }, + "description": { + "en": "An agent whose install command is a user-installed tool could fail with \"command not found\" on a machine that has that tool. OpenWorker is the case: it installs with `uv tool install`, uv's own installer puts the binary in ~/.local/bin, and an application started from the Dock inherits none of the PATH a shell profile sets up. The pre-flight check had already looked in the right place and let the install proceed, so the failure arrived several seconds later as a bare exit code. The launcher now puts the standard user tool directories back on its own search path at startup, appended so anything you configured yourself still takes precedence.", + "zh": "当某个 Agent 的安装命令本身是一个由用户自行安装的工具时,即便机器上确实装了该工具,安装仍可能以「command not found」失败。OpenWorker 即是如此:它通过 `uv tool install` 安装,而 uv 自身的安装脚本会把可执行文件放在 ~/.local/bin,从程序坞启动的应用则完全继承不到 shell 配置文件所设置的 PATH。安装前的依赖检查恰恰查看了正确的位置并放行,因此失败要等到数秒之后才以一个孤立的退出码呈现。现在启动器会在启动时把标准的用户工具目录补回自身的查找路径,且采用追加方式,你自行配置的路径仍然优先。" + } + } + ] +} diff --git a/packages/launcher/package.json b/packages/launcher/package.json index 093c98385..dd65a6703 100644 --- a/packages/launcher/package.json +++ b/packages/launcher/package.json @@ -1,6 +1,6 @@ { "name": "openagents-launcher", - "version": "0.9.26", + "version": "0.9.27", "description": "OpenAgents Launcher — install, configure, and manage your AI agents", "main": "out/main/index.js", "homepage": "https://github.com/openagents-org/openagents", diff --git a/packages/launcher/src/main/agent-manager.ts b/packages/launcher/src/main/agent-manager.ts index c2c182d41..e2a59e357 100644 --- a/packages/launcher/src/main/agent-manager.ts +++ b/packages/launcher/src/main/agent-manager.ts @@ -35,6 +35,7 @@ import { KEY_OPTIONAL_LOGIN_AGENTS, launcherAuthFields, } from "./agents/auth-specs" +import { codebuddyLoginEnv } from "./agents/codebuddy-signin" import { normalizeEnvForSave, normalizeWorkspaceEndpoint, @@ -647,13 +648,64 @@ export class AgentManager extends EventEmitter { return this.healthCheck(type) } - /** Saved type-level env for a probe (e.g. AMP_URL / AMP_API_KEY), never thrown. */ + /** + * The saved env a launcher-side CLI spawn for this TYPE should carry — the + * sign-in probe (a configured AMP_URL / AMP_API_KEY) and the login terminal + * (which CodeBuddy site to sign into) both read it. + * + * Type-level env first, then the instance env of every agent of that type + * layered over it. Both levels are real: onboarding and the Install page save + * type-level, while Configure on an existing agent saves per-instance — so + * reading only the type would hand the probe a value the user has since + * changed. With more than one agent of a type the last one wins, which is the + * best a per-TYPE answer can do and matches what the probe is: a question + * about the CLI's own state, not about one agent. + * + * Reads the connector's raw list rather than getAgents(). getAgents() derives + * health, health asks the sign-in probe, and the probe lands back here — so + * going through it would recurse until the stack ran out, on every dual-login + * agent, the first time the list was built. + */ private _savedTypeEnvForProbe(type: string): Record { + let env: Record = {} + try { + env = { ...((this.getAgentEnv(type) as Record) || {}) } + } catch { + /* no type-level env saved yet */ + } try { - return (this.getAgentEnv(type) as Record) || {} + const listAgents = this._connector?.listAgents as + | (() => unknown[]) + | undefined + const agents = (listAgents?.call(this._connector) || []) as Array<{ + type?: string + env?: Record + }> + for (const agent of agents) { + if (agent.type !== type) continue + for (const [k, v] of Object.entries(agent.env || {})) { + if ((v || "").trim()) env[k] = v + } + } } catch { - return {} + /* the daemon config isn't readable — the type-level env still stands */ } + return env + } + + /** + * Extra environment the sign-in terminal for this agent type has to carry. + * + * A CLI that fronts several services signs into whichever one its environment + * selects, and a sign-in on the wrong one is indistinguishable from no sign-in + * at all once the agent runs. CodeBuddy is the case: launched bare it signs in + * against the international site, while an agent configured for the China site + * runs with CODEBUDDY_INTERNET_ENVIRONMENT=internal and cannot use that + * session. Empty for every other agent. + */ + loginEnvFor(type: string): Record { + if (type !== "codebuddy") return {} + return codebuddyLoginEnv(this._savedTypeEnvForProbe(type)) } /** diff --git a/packages/launcher/src/main/agents/auth-specs.test.ts b/packages/launcher/src/main/agents/auth-specs.test.ts index 44f2da83b..2f223bdf0 100644 --- a/packages/launcher/src/main/agents/auth-specs.test.ts +++ b/packages/launcher/src/main/agents/auth-specs.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest" -import { CORE_AGENTS, launcherAuthFields } from "./auth-specs" +import { + CORE_AGENTS, + CREDENTIAL_ENV, + DUAL_LOGIN_AGENTS, + keylessAuth, + launcherAuthFields, +} from "./auth-specs" +import { loginVerdict } from "./login-probe" /** * Field ORDER is part of the contract, not cosmetics. @@ -14,9 +21,9 @@ describe("launcher auth field order", () => { const nameOf = (f: Record): string => String(f.name || "") for (const type of CORE_AGENTS) { - const fields = launcherAuthFields(type) as - | Array> - | null + const fields = launcherAuthFields(type) as Array< + Record + > | null if (!fields) continue it(`${type}: credentials come before the model field`, () => { @@ -48,3 +55,79 @@ describe("launcher auth field order", () => { ]) }) }) + +/** + * Command Code's `status`, verbatim (v1.36.0). `whoami` used to be the probe and + * reports an unreachable account service as "Error: Connection error." on a + * CLEAN exit — which, with only a signed-out pattern to go on, reads as SIGNED + * IN. `status` says which of the three things happened. + */ +describe("commandcode sign-in probe", () => { + const spec = DUAL_LOGIN_AGENTS.commandcode + const SIGNED_IN = + "✔ Authentication verified\n✔ Authenticated as ada\n Provider: Command Code\n" + const SIGNED_OUT = + "✖ Not authenticated\n\nRun cmd auth login to authenticate.\n" + const UNREACHABLE = "✖ Status check failed: fetch failed\n" + + it("reads the CLI's own authentication status", () => { + expect(loginVerdict(spec, SIGNED_IN, 0)).toBe(true) + expect(loginVerdict(spec, SIGNED_OUT, 1)).toBe(false) + }) + + it("never lets the signed-out copy match the signed-in pattern", () => { + // "Not authenticated" / "to authenticate" both contain the word; only the + // verified / "as " wording may stand for success. + expect(spec.loggedInPattern?.test(SIGNED_OUT)).toBe(false) + }) + + it("stays unknown when the account service is unreachable", () => { + // Not a verdict — health.ts treats unknown optimistically, so an offline + // machine never reports a signed-in user as signed out. + expect(loginVerdict(spec, UNREACHABLE, 1)).toBe(null) + }) +}) + +describe("keyless auth paths", () => { + it("counts OpenWorker's no-key providers as configured", () => { + expect( + keylessAuth("openworker", { OPENWORKER_PROVIDER: "ollama" }), + ).toEqual({ keyless: true, authMode: null }) + // Reusing a ChatGPT sign-in out of a state dir really is a CLI login. + expect( + keylessAuth("openworker", { OPENWORKER_PROVIDER: "openai-codex" }), + ).toEqual({ keyless: true, authMode: "cli_login" }) + }) + + it("still demands a key for every other provider, and by default", () => { + expect( + keylessAuth("openworker", { OPENWORKER_PROVIDER: "anthropic" }).keyless, + ).toBe(false) + // Unset falls through to OpenWorker's own default, which is a key provider. + expect(keylessAuth("openworker", {}).keyless).toBe(false) + expect( + keylessAuth("claude", { OPENWORKER_PROVIDER: "ollama" }).keyless, + ).toBe(false) + }) + + it("lets the instance env win over the type env", () => { + // Configure saves per-instance; onboarding saved per-type. + expect( + keylessAuth( + "openworker", + { OPENWORKER_PROVIDER: "openai" }, + { OPENWORKER_PROVIDER: "ollama" }, + ).keyless, + ).toBe(false) + }) +}) + +describe("credential env", () => { + it("counts CodeBuddy's platform token, which is not an API key", () => { + // check_ready lists it as a first-class auth path; judging the agent on + // *_API_KEY alone left a token-configured agent reading "Login required". + expect(CREDENTIAL_ENV.test("CODEBUDDY_AUTH_TOKEN")).toBe(true) + // Still narrow: a GitHub token authenticates nothing about the model. + expect(CREDENTIAL_ENV.test("GITHUB_TOKEN")).toBe(false) + }) +}) diff --git a/packages/launcher/src/main/agents/auth-specs.ts b/packages/launcher/src/main/agents/auth-specs.ts index 1772b5b77..6e9eaf5ff 100644 --- a/packages/launcher/src/main/agents/auth-specs.ts +++ b/packages/launcher/src/main/agents/auth-specs.ts @@ -9,6 +9,11 @@ * than a new branch in five places. */ +import { + CODEBUDDY_SESSION_FILES, + codebuddySessionMatchesRegion, +} from "./codebuddy-signin" + /** * Launcher-side auth overrides for agents that authenticate with an API key / * base URL. These agents ship in the shared registry with an interactive @@ -369,6 +374,19 @@ export interface HostedLoginSpec { // tried in order; the first hit wins. `key` names a JSON field that has to // hold a value — without it the file only has to exist. credsFiles?: Array<{ path: string; key?: string }> + /** + * An extra condition on a creds file that was found and parsed, for a CLI + * that keeps ONE session covering several services. CodeBuddy is the case: + * the same file holds the international, China and iOA sign-ins, tagged with + * the domain they came from, so "a session exists" is not yet "a session this + * agent can use". Returning false here is a real signed-OUT verdict for this + * agent's configuration, which is the honest answer — the alternative is + * reporting Ready for an agent whose every task fails on auth. + * + * Receives the parsed creds and the agent's saved env; must read only what it + * needs and never log it. + */ + credsGuard?: (creds: unknown, env: Record) => boolean // Env vars wiped when the user signs in via the browser flow. Hosted-login // agents have no env UI (getEnvFields → []), so any saved value is stale // leftover that overrides the login session — e.g. an invalid CURSOR_API_KEY @@ -393,7 +411,56 @@ export interface HostedLoginSpec { * Deliberately narrow — matching every `*_TOKEN` would count things like a * GitHub token, which authenticates nothing about the model. */ -export const CREDENTIAL_ENV = /API_KEY$|^CLAUDE_CODE_OAUTH_TOKEN$/ +export const CREDENTIAL_ENV = + /API_KEY$|^CLAUDE_CODE_OAUTH_TOKEN$|^CODEBUDDY_AUTH_TOKEN$/ + +/** + * Agents whose credential requirement depends on one of their OWN settings, so + * "no API key saved" is not the same as "not configured". + * + * OpenWorker is bring-your-own-model across ~20 providers, and two of them ask + * for no key at all: `ollama` talks to a local server, and `openai-codex` + * reuses an existing ChatGPT sign-in out of the state directory. Judged on the + * key alone both read "No model API key" forever — an agent that runs fine, + * permanently badged as broken. The value maps to the auth_mode to report: + * openai-codex really is a CLI sign-in, while ollama is neither and says so by + * reporting none. + * + * Only a value listed here counts as keyless; every other provider still needs + * its key, and an unset setting falls through to the agent's default (which for + * OpenWorker is `openai`, a key provider). + */ +export const KEYLESS_AUTH_SETTINGS: Record< + string, + { setting: string; values: Record } +> = { + openworker: { + setting: "OPENWORKER_PROVIDER", + values: { ollama: null, "openai-codex": "cli_login" }, + }, +} + +/** + * Whether this agent's saved settings select a keyless auth path, and what to + * label it. `envs` are consulted in order and the first one that names the + * setting decides — pass the instance env before the type env, since Configure + * writes per-instance while onboarding writes per-type. + */ +export function keylessAuth( + type: string, + ...envs: Array | undefined> +): { keyless: boolean; authMode: string | null } { + const none = { keyless: false, authMode: null } + const rule = KEYLESS_AUTH_SETTINGS[type] + if (!rule) return none + for (const env of envs) { + const value = (env?.[rule.setting] || "").trim().toLowerCase() + if (!value) continue + if (!Object.prototype.hasOwnProperty.call(rule.values, value)) return none + return { keyless: true, authMode: rule.values[value] } + } + return none +} /** * Readiness reason codes surfaced to the Agents list. These MUST match the @@ -549,18 +616,49 @@ export const DUAL_LOGIN_AGENTS: Record = { // entry point, and `cmd` IS the Windows command shell — a terminal handed // `cmd login` on Windows opens a shell prompt instead of signing anyone in. loginCommand: "command-code login", - // NEGATIVE matching on purpose. The probe treats an unmatched - // loggedOutPattern as signed-in and an unmatched loggedInPattern as signed - // OUT, so a positive pattern that drifts locks a signed-in user behind a - // "Login required" they cannot clear. These strings are the CLI's own - // unauthenticated copy ("Not authenticated. Please login using …", the - // /alpha/whoami failure). If this drifts, the cost is only that the run - // itself reports the miss — the adapter maps exit code 3 to an actionable - // sign-in error. - statusArgs: ["whoami"], + // `status` is the CLI's own "Show authentication status", and it reads the + // answer three ways where `whoami` (what this used to spawn) reads it two: + // "Authentication verified" / "Authenticated as " on exit 0 when + // signed in, "Not authenticated" on exit 1 when signed out, and "Status + // check failed: …" on exit 1 when its account service is unreachable. + // `whoami` reports that last case as "Error: Connection error." on a CLEAN + // exit, which — matching only the signed-out wording, as this spec used to — + // is indistinguishable from a successful whoami and reads as SIGNED IN. + // + // So BOTH directions are named, positive first: the signed-out copy carries + // "authenticate" as well ("Run cmd auth login to authenticate."), and only + // the verified / `as ` wording may stand for success. An unreachable + // service then matches neither on a non-zero exit and stays UNKNOWN, which + // health.ts treats optimistically — an offline machine never demotes a + // signed-in user. If either pattern drifts the verdict falls back to that + // same unknown rather than locking anyone out; the run itself still reports + // the miss, and the adapter maps exit code 3 to an actionable sign-in error. + statusArgs: ["status"], + loggedInPattern: /authentication verified|authenticated as/i, loggedOutPattern: /not authenticated|not signed in|not logged in/i, apiKeyEnv: "COMMAND_CODE_API_KEY", }, + codebuddy: { + // CodeBuddy Code has no login subcommand at all (verified on 2.146.0: the + // CLI exposes config/mcp/plugin/daemon/… and nothing auth-shaped). Signing + // in is `/login` INSIDE the interactive session, so the login command is the + // bare binary — needsRealTerminal sends it straight to a terminal window, + // like Copilot's — and sign-in has to be read off disk. + // + // What it leaves behind is one session file in the CLI's extension data + // directory. One, for every site it can talk to, tagged with the domain it + // came from — so `credsGuard` checks that the tag agrees with the region + // this agent is configured for. Without that check a user who signed in on + // codebuddy.ai while their agent is pinned to the China site reads as + // "Ready" and then fails auth on every message. See codebuddy-signin.ts. + loginCommand: "codebuddy", + statusArgs: [], + credsFiles: CODEBUDDY_SESSION_FILES.map((path) => ({ path, key: "auth" })), + credsGuard: codebuddySessionMatchesRegion, + apiKeyEnv: "CODEBUDDY_API_KEY", + terminalHint: + "Type /login to sign in. This window is already pointed at the site this agent is configured for; close it once the CLI says you are signed in.", + }, } /** @@ -615,9 +713,11 @@ export const KEY_OPTIONAL_LOGIN_AGENTS = new Set([ // Its login_command is the BARE binary, like Copilot's — there is no // `codebuddy login` subcommand at all: signing in is the `/login` slash // command inside the interactive session, so the terminal we open drops the - // user into the CLI where they run it. That is also why the registry marks it - // `unverifiable`: with the key fields empty there is nothing on disk this - // process is entitled to read, and the first task is what confirms auth. + // user into the CLI where they run it. Like gemini it is ALSO in + // DUAL_LOGIN_AGENTS, which is what gives that sign-in a probe: the session + // file the `/login` flow writes (see codebuddy-signin.ts). The registry still + // marks it `unverifiable` because the core has no per-platform creds path to + // look at — the launcher does. "codebuddy", ]) diff --git a/packages/launcher/src/main/agents/codebuddy-signin.test.ts b/packages/launcher/src/main/agents/codebuddy-signin.test.ts new file mode 100644 index 000000000..783f0dbc4 --- /dev/null +++ b/packages/launcher/src/main/agents/codebuddy-signin.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" + +import { + CODEBUDDY_SESSION_FILES, + codebuddyLoginEnv, + codebuddySessionMatchesRegion, +} from "./codebuddy-signin" + +/** + * The bug this file exists for: the sign-in terminal opened bare, so a user who + * had picked the China site in the launcher signed in against codebuddy.ai — + * the terminal said "logged in", and every message the agent then sent failed + * on auth, because the agent runs with CODEBUDDY_INTERNET_ENVIRONMENT=internal. + */ +describe("codebuddyLoginEnv", () => { + it("pins the sign-in terminal to the China site", () => { + // The same value the adapter overlays on the agent's own runs. + expect(codebuddyLoginEnv({ CODEBUDDY_REGION: "china" })).toEqual({ + CODEBUDDY_INTERNET_ENVIRONMENT: "internal", + }) + }) + + it("pins nothing for the international site", () => { + // Deliberate: unset is the CLI's documented default AND what lets its + // startup follow the session on disk. There is no product.external.json to + // select, so naming a value here would break the international sign-in. + expect(codebuddyLoginEnv({ CODEBUDDY_REGION: "international" })).toEqual({}) + expect(codebuddyLoginEnv({})).toEqual({}) + expect(codebuddyLoginEnv(undefined)).toEqual({}) + }) + + it("falls back to the default for a value it does not know", () => { + expect(codebuddyLoginEnv({ CODEBUDDY_REGION: "mars" })).toEqual({}) + }) + + it("is case- and whitespace-insensitive, like the adapter", () => { + expect(codebuddyLoginEnv({ CODEBUDDY_REGION: " China " })).toEqual({ + CODEBUDDY_INTERNET_ENVIRONMENT: "internal", + }) + }) +}) + +describe("codebuddySessionMatchesRegion", () => { + const cn = { CODEBUDDY_REGION: "china" } + + it("rejects an international session under a China-pinned agent", () => { + expect( + codebuddySessionMatchesRegion( + { auth: { domain: "www.codebuddy.ai" } }, + cn, + ), + ).toBe(false) + }) + + it("accepts every China host the CLI signs in from", () => { + for (const domain of [ + "www.codebuddy.cn", + "staging.codebuddy.cn", + "www.workbuddy.cn", + "staging.workbuddy.cn", + "copilot.tencent.com", + "staging-copilot.tencent.com", + ]) { + expect(codebuddySessionMatchesRegion({ auth: { domain } }, cn)).toBe(true) + } + }) + + it("accepts any session when the region pins nothing", () => { + // international lets the CLI follow the session's own domain, so a China + // sign-in under an "international" agent works and must not be refused. + const anywhere = { auth: { domain: "www.codebuddy.cn" } } + expect(codebuddySessionMatchesRegion(anywhere, {})).toBe(true) + expect( + codebuddySessionMatchesRegion(anywhere, { + CODEBUDDY_REGION: "international", + }), + ).toBe(true) + }) + + it("does not refuse a session that records no domain", () => { + expect(codebuddySessionMatchesRegion({ auth: {} }, cn)).toBe(true) + expect(codebuddySessionMatchesRegion(null, cn)).toBe(true) + }) +}) + +describe("session file locations", () => { + it("covers all three platforms, home-relative", () => { + expect(CODEBUDDY_SESSION_FILES).toEqual([ + "Library/Application Support/CodeBuddyExtension/Data/Public/auth/Tencent-Cloud.coding-copilot.info", + "AppData/Local/CodeBuddyExtension/Data/Public/auth/Tencent-Cloud.coding-copilot.info", + ".local/share/CodeBuddyExtension/Data/Public/auth/Tencent-Cloud.coding-copilot.info", + ]) + }) +}) diff --git a/packages/launcher/src/main/agents/codebuddy-signin.ts b/packages/launcher/src/main/agents/codebuddy-signin.ts new file mode 100644 index 000000000..89ee135c8 --- /dev/null +++ b/packages/launcher/src/main/agents/codebuddy-signin.ts @@ -0,0 +1,123 @@ +/** + * CodeBuddy's two-sided sign-in, as the launcher has to model it. + * + * CodeBuddy Code is one CLI in front of several separate services: the + * international site (codebuddy.ai / workbuddy.ai), the China site + * (codebuddy.cn / workbuddy.cn, reached through copilot.tencent.com), Tencent's + * internal iOA build, and enterprise deployments. `CODEBUDDY_INTERNET_ENVIRONMENT` + * is what picks one — and the CLI keeps exactly ONE session on disk, tagged with + * the domain it belongs to. Three consequences drive everything in this file: + * + * 1. THE SIGN-IN TERMINAL HAS TO BE PINNED. Launched with no environment, the + * CLI signs in against the international site. An agent configured for the + * China site then runs with CODEBUDDY_INTERNET_ENVIRONMENT=internal against + * a codebuddy.ai session and fails to authenticate — which is what "I chose + * China, the terminal says I'm logged in, and it still doesn't work" is. + * `codebuddyLoginEnv` gives the terminal the same value the agent runs with. + * + * 2. THE SESSION IS READABLE, THE STATUS IS NOT. There is no `codebuddy login + * status` (verified against 2.146.0: the CLI has no login/auth/whoami + * subcommand at all — signing in is the `/login` slash command inside the + * interactive session). What the sign-in does leave is a session file, in + * the CLI's own extension data directory, named after the `authentication.id` + * in its product.json. Its existence is the sign-in probe. + * + * 3. A SESSION FOR THE OTHER SITE IS WORSE THAN NO SESSION. The file records + * `auth.domain`, so an agent pinned to the China site can tell a China + * sign-in from an international one instead of reporting "Ready" for an + * agent that cannot authenticate. Only the domain is read; the token in the + * same file is never touched. + */ + +/** Where the CLI keeps its data, per platform, relative to the home dir. */ +const EXTENSION_DIRS = [ + // macOS + "Library/Application Support/CodeBuddyExtension", + // Windows + "AppData/Local/CodeBuddyExtension", + // Linux + ".local/share/CodeBuddyExtension", +] + +/** + * The session file's name, from `authentication.id` in the CLI's product.json. + * The per-environment product configs (product.internal.json, product.ioa.json, + * …) override only the endpoint and model list, so every site shares this one + * file — which is exactly why the domain inside it has to be checked. + */ +const SESSION_FILE = "Tencent-Cloud.coding-copilot.info" + +/** + * Home-relative paths to try, in order, for the CLI's session file. Only one + * exists on any given machine; the others are simply absent and skipped. + */ +export const CODEBUDDY_SESSION_FILES = EXTENSION_DIRS.map( + (dir) => `${dir}/Data/Public/auth/${SESSION_FILE}`, +) + +/** + * The regions the launcher offers (registry `CODEBUDDY_REGION`), mapped to the + * CLI's own `CODEBUDDY_INTERNET_ENVIRONMENT` value and the domains an account on + * that site signs in from. + * + * `international` deliberately forces NOTHING. The CLI's startup derives the + * environment from the session file when the variable is unset, so leaving it + * alone is both the documented default AND the setting that adapts to whatever + * the user actually signed into — there is no `product.external.json` to select. + * Only a region that pins the variable can disagree with a session. + */ +const REGIONS: Record = { + international: {}, + china: { + env: "internal", + // The CLI's own internalDomain list, as a suffix match so the staging hosts + // (staging-copilot.tencent.com, staging.codebuddy.cn) match too. + domains: /(?:codebuddy|workbuddy)\.cn$|copilot\.tencent\.com$/i, + }, +} + +/** The configured region, normalized; unknown values fall back to the default. */ +function regionOf(env: Record | undefined): { + env?: string + domains?: RegExp +} { + const raw = String(env?.CODEBUDDY_REGION || "") + .trim() + .toLowerCase() + return REGIONS[raw] || REGIONS.international +} + +/** + * The environment a CodeBuddy CLI must be launched with to reach the site this + * agent is configured for — for the sign-in terminal, so the account the user + * signs into is the account the agent will run as. + * + * Mirrors the adapter's own overlay (agent-connector's codebuddy-stream + * `resolveCodeBuddyEnv`); keep the two in step when a region is added. + */ +export function codebuddyLoginEnv( + env: Record | undefined, +): Record { + const value = regionOf(env).env + return value ? { CODEBUDDY_INTERNET_ENVIRONMENT: value } : {} +} + +/** + * Whether the session on disk can authenticate THIS agent: true when it belongs + * to the configured site, false when it belongs to another one, and true when + * the region pins nothing (the CLI then follows the session's own domain) or the + * session predates the domain field and there is nothing to disagree with. + * + * `creds` is the parsed session file. Only `auth.domain` is read. + */ +export function codebuddySessionMatchesRegion( + creds: unknown, + env: Record | undefined, +): boolean { + const domains = regionOf(env).domains + if (!domains) return true + const auth = (creds as { auth?: { domain?: unknown } } | null)?.auth + const domain = typeof auth?.domain === "string" ? auth.domain.trim() : "" + if (!domain) return true + return domains.test(domain) +} diff --git a/packages/launcher/src/main/agents/health.test.ts b/packages/launcher/src/main/agents/health.test.ts new file mode 100644 index 000000000..89d38a986 --- /dev/null +++ b/packages/launcher/src/main/agents/health.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest" + +import { HealthResolver, type HealthResolverDeps } from "./health" + +/** + * The launcher's readiness verdict for an agent that needs no credential. + * + * OpenWorker is bring-your-own-model, and two of its providers ask for no key: + * `ollama` is a local server and `openai-codex` reuses an existing ChatGPT + * sign-in. Judged on the key alone — which is all the core's check_ready can do + * — both sat at "No model API key" for as long as they ran. + */ +function resolver( + typeEnv: Record = {}, + overrides: Partial = {}, +): HealthResolver { + return new HealthResolver({ + isInstalled: () => true, + getInstalledVersion: () => "1.0.0", + getTypeEnv: () => typeEnv, + loginIsAuthed: () => null, + getRegistryEntry: () => null, + ...overrides, + }) +} + +const NOT_READY = { + installed: true, + ready: false, + reason: "login_required", + auth_mode: null, + message: "No model API key — pick a provider and set OPENWORKER_API_KEY", +} + +describe("readiness for a keyless provider", () => { + it("is Ready with no key at all when the provider needs none", () => { + const h = resolver({ OPENWORKER_PROVIDER: "ollama" }).reconcileAgentHealth( + "openworker", + {}, + NOT_READY, + ) as Record + expect(h.ready).toBe(true) + expect(h.reason).toBe("ready") + // Neither a key nor a sign-in — reporting "API key" would be a lie. + expect(h.auth_mode).toBe(null) + }) + + it("labels a reused ChatGPT sign-in as the CLI login it is", () => { + const h = resolver({}).reconcileAgentHealth( + "openworker", + { OPENWORKER_PROVIDER: "openai-codex" }, + NOT_READY, + ) as Record + expect(h.ready).toBe(true) + expect(h.auth_mode).toBe("cli_login") + }) + + it("leaves a key provider needing its key", () => { + const h = resolver({ + OPENWORKER_PROVIDER: "anthropic", + }).reconcileAgentHealth("openworker", {}, NOT_READY) as Record< + string, + unknown + > + expect(h.ready).toBe(false) + expect(h.reason).toBe("login_required") + }) + + it("still prefers a configured key over the keyless label", () => { + const h = resolver({ + OPENWORKER_PROVIDER: "ollama", + OPENWORKER_API_KEY: "sk-x", + }).reconcileAgentHealth("openworker", {}, NOT_READY) as Record< + string, + unknown + > + expect(h.ready).toBe(true) + expect(h.auth_mode).toBe("api_key") + }) +}) + +describe("readiness for CodeBuddy's platform token", () => { + it("counts CODEBUDDY_AUTH_TOKEN as configured credentials", () => { + // The registry lists it beside the API key as a first-class auth path, but + // it is not an *_API_KEY, so the agent used to read "Login required" with a + // perfectly good token saved. + const h = resolver({}).reconcileAgentHealth( + "codebuddy", + { CODEBUDDY_AUTH_TOKEN: "eyJhbGciOi" }, + { installed: true, ready: false, reason: "login_required" }, + ) as Record + expect(h.ready).toBe(true) + expect(h.auth_mode).toBe("api_key") + }) +}) diff --git a/packages/launcher/src/main/agents/health.ts b/packages/launcher/src/main/agents/health.ts index 7ec8ecc90..072b37955 100644 --- a/packages/launcher/src/main/agents/health.ts +++ b/packages/launcher/src/main/agents/health.ts @@ -15,6 +15,7 @@ import { CREDENTIAL_ENV, DUAL_LOGIN_AGENTS, HOSTED_LOGIN_AGENTS, + keylessAuth, READY_REASON, } from "./auth-specs" @@ -51,6 +52,23 @@ export class HealthResolver { } } + /** + * The agent's settings select a path that needs no credential of any kind — + * OpenWorker pointed at a local ollama, or reusing a ChatGPT sign-in out of a + * state directory. Judged on the key alone those read "not configured" for as + * long as they run. See KEYLESS_AUTH_SETTINGS. + */ + keylessAuth( + type: string, + instanceEnv?: Record, + ): { keyless: boolean; authMode: string | null } { + try { + return keylessAuth(type, instanceEnv, this.deps.getTypeEnv(type)) + } catch { + return { keyless: false, authMode: null } + } + } + /** Registry's not-ready hint for an agent type, with a sensible fallback. */ notReadyMessage(type: string): string { try { @@ -208,13 +226,15 @@ export class HealthResolver { // correctly by the core via its marker check, so getInstalledVersion being // null there means "leave the core's verdict alone". if (!this.deps.getInstalledVersion(type)) return health - const ready = this.hasConfiguredCredentials(type) + const keyless = this.keylessAuth(type) + const hasKey = this.hasConfiguredCredentials(type) + const ready = hasKey || keyless.keyless return { ...h, installed: true, ready, reason: ready ? READY_REASON.READY : READY_REASON.LOGIN_REQUIRED, - auth_mode: ready ? "api_key" : null, + auth_mode: hasKey ? "api_key" : keyless.authMode, execution_mode: ready ? h.execution_mode || "direct" : "unavailable", // Binary confirmed on disk → never "not installed"; show login-required. message: ready ? "Ready" : this.loginRequiredMessage(type), @@ -295,7 +315,11 @@ export class HealthResolver { // signed-in session. Only fall back to "cli_login" when no key is set. const hasKey = this.envHasApiKey(instanceEnv) || this.hasConfiguredCredentials(type) - const hasCreds = cliLoggedIn || hasKey + // …and an agent whose own settings pick a keyless path (OpenWorker on a + // local ollama, or reusing a ChatGPT sign-in) is configured with no key at + // all, so it must not be judged on one. + const keyless = this.keylessAuth(type, instanceEnv) + const hasCreds = cliLoggedIn || hasKey || keyless.keyless // The type-level health is populated asynchronously (see // _scheduleHealthRefresh), so right after onboarding it is still null. Don't // fall back to a misleading "Not configured" when the agent actually has a @@ -306,7 +330,11 @@ export class HealthResolver { installed: true, ready: true, reason: READY_REASON.READY, - auth_mode: hasKey ? "api_key" : "cli_login", + auth_mode: hasKey + ? "api_key" + : cliLoggedIn + ? "cli_login" + : keyless.authMode, execution_mode: "direct", message: "Ready", } @@ -324,7 +352,11 @@ export class HealthResolver { if (h.auth_mode) return health return { ...h, - auth_mode: hasKey ? "api_key" : cliLoggedIn ? "cli_login" : null, + auth_mode: hasKey + ? "api_key" + : cliLoggedIn + ? "cli_login" + : keyless.authMode, } } if (hasCreds) { @@ -333,7 +365,11 @@ export class HealthResolver { installed: true, ready: true, reason: READY_REASON.READY, - auth_mode: hasKey ? "api_key" : "cli_login", + auth_mode: hasKey + ? "api_key" + : cliLoggedIn + ? "cli_login" + : keyless.authMode, execution_mode: h.execution_mode && h.execution_mode !== "unavailable" ? h.execution_mode diff --git a/packages/launcher/src/main/agents/login-probe.test.ts b/packages/launcher/src/main/agents/login-probe.test.ts index 1cfa9bc10..4b2c68c50 100644 --- a/packages/launcher/src/main/agents/login-probe.test.ts +++ b/packages/launcher/src/main/agents/login-probe.test.ts @@ -108,3 +108,79 @@ describe("credsVerdict — Gemini's sign-in, read off disk", () => { expect(credsVerdict(gemini, home)).toBe(null) }) }) + +describe("credsVerdict — CodeBuddy's session, and which site it is for", () => { + const codebuddy = DUAL_LOGIN_AGENTS.codebuddy + let home: string + + // The macOS path, which is the first CODEBUDDY_SESSION_FILES entry. The other + // two are simply absent under this fake home and get skipped. + const SESSION = path.join( + "Library", + "Application Support", + "CodeBuddyExtension", + "Data", + "Public", + "auth", + "Tencent-Cloud.coding-copilot.info", + ) + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "codebuddy-home-")) + }) + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }) + }) + + const session = (body: unknown): void => { + const file = path.join(home, SESSION) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify(body)) + } + + it("says signed out when /login has never run", () => { + expect(credsVerdict(codebuddy, home)).toBe(false) + }) + + it("reads a session as signed in when the agent pins no site", () => { + // international leaves CODEBUDDY_INTERNET_ENVIRONMENT unset, so the CLI + // follows whatever the session itself says — nothing can disagree. + session({ auth: { accessToken: "x", domain: "www.codebuddy.ai" } }) + expect(credsVerdict(codebuddy, home)).toBe(true) + expect( + credsVerdict(codebuddy, home, { CODEBUDDY_REGION: "international" }), + ).toBe(true) + expect(credsVerdict(codebuddy, home, { CODEBUDDY_REGION: "china" })).toBe( + false, + ) + }) + + it("accepts a China session for a China agent, on every China host", () => { + for (const domain of [ + "www.codebuddy.cn", + "staging.codebuddy.cn", + "www.workbuddy.cn", + "copilot.tencent.com", + "staging-copilot.tencent.com", + ]) { + session({ auth: { accessToken: "x", domain } }) + expect(credsVerdict(codebuddy, home, { CODEBUDDY_REGION: "china" })).toBe( + true, + ) + } + }) + + it("does not count a session with no token as a sign-in", () => { + session({ account: { uid: "1" } }) + expect(credsVerdict(codebuddy, home)).toBe(false) + }) + + it("keeps a session whose domain it cannot read", () => { + // An older CLI wrote no domain. Being signed in is certain; the site is + // not, and refusing on that would lock out a working agent. + session({ auth: { accessToken: "x" } }) + expect(credsVerdict(codebuddy, home, { CODEBUDDY_REGION: "china" })).toBe( + true, + ) + }) +}) diff --git a/packages/launcher/src/main/agents/login-probe.ts b/packages/launcher/src/main/agents/login-probe.ts index e42ccd2aa..39b67198d 100644 --- a/packages/launcher/src/main/agents/login-probe.ts +++ b/packages/launcher/src/main/agents/login-probe.ts @@ -38,15 +38,21 @@ import { windowsExecutable } from "../win-exec" /** * Sign-in read off disk, for a CLI whose auth is an interactive TUI flow with * no status command to spawn (Gemini — spawning bare `gemini` for a probe would - * launch its TUI and hang). Each entry is tried in order and the first hit wins. + * launch its TUI and hang; CodeBuddy, whose sign-in is a `/login` slash command + * inside its session). Each entry is tried in order and the first hit wins. * * Absent evidence is a real "signed out", not an unknown: only a file we can * see but cannot read leaves the verdict null, because that is the one case * where the user may well be signed in and we simply cannot tell. + * + * A found session still has to be USABLE by this agent: `spec.credsGuard` gets + * the parsed file and the agent's saved env, for a CLI that keeps one session + * across several services. Nothing here is logged — the same files hold tokens. */ export function credsVerdict( spec: HostedLoginSpec, homeDir: string = os.homedir(), + env: Record = {}, ): boolean | null { let value: boolean | null = false for (const c of spec.credsFiles || []) { @@ -58,7 +64,12 @@ export function credsVerdict( const field = (parsed as Record | null)?.[c.key] // `active: null` is how Gemini records a signed-OUT account, so the field // has to carry a value — the file's existence proves nothing. - if (typeof field === "string" ? !!field.trim() : !!field) return true + if (typeof field === "string" ? !field.trim() : !field) continue + // A session that exists but belongs to another service (CodeBuddy's + // international sign-in under a China-pinned agent) authenticates nothing + // here, and saying so is the difference between "Login required" and a + // green Ready in front of an agent whose every message fails on auth. + return spec.credsGuard ? spec.credsGuard(parsed, env) : true } catch { value = null } @@ -214,9 +225,13 @@ export class LoginProbe { resolve(value) } - // Sign-in read off disk, for a CLI with no status command (Gemini). + // Sign-in read off disk, for a CLI with no status command (Gemini, + // CodeBuddy). The saved env comes along because a session can be valid + // for one of an agent's configured services and not another. if (spec.credsFiles?.length) { - settle(credsVerdict(spec)) + settle( + credsVerdict(spec, os.homedir(), this.deps.getSavedTypeEnv(type)), + ) return } diff --git a/packages/launcher/src/main/bootstrap/node-runtime.ts b/packages/launcher/src/main/bootstrap/node-runtime.ts index 4f19db11e..5ed10b755 100644 --- a/packages/launcher/src/main/bootstrap/node-runtime.ts +++ b/packages/launcher/src/main/bootstrap/node-runtime.ts @@ -45,6 +45,62 @@ export function ensureBundledRuntimeFirstOnPath(): void { writePathEnv([PORTABLE_NODE_DIR, ...filtered].join(sep)) } +/** + * Directories a user-level installer drops a binary into, which a GUI-launched + * app never has on PATH. + * + * `~/.local/bin` is the one that matters most: it is where uv's own + * `curl … | sh` installer puts `uv`, where pipx and `pip --user` put theirs, + * and (on Windows too) where uv installs by default. The Homebrew and + * /usr/local entries are for a Terminal-installed tool on a machine whose + * launchd PATH predates them. + */ +function userBinDirs(home: string = os.homedir()): string[] { + const dirs = [ + path.join(home, ".local", "bin"), + path.join(home, ".cargo", "bin"), + ] + if (process.platform !== "win32") { + dirs.push("/opt/homebrew/bin", "/usr/local/bin") + } + return dirs +} + +/** + * Put the user-level bin directories back on this process's PATH. + * + * A GUI app is started by launchd (or explorer.exe), not by a shell, so it + * inherits a minimal PATH — on macOS `/usr/bin:/bin:/usr/sbin:/sbin` — and + * never sees the line a tool's installer appended to the user's shell profile. + * Everything the launcher spawns copies `process.env` as its base, so the gap + * propagates: OpenWorker's install command IS `uv tool install …`, and on a + * machine with uv sitting in ~/.local/bin it died with `/bin/sh: uv: command + * not found` and exit 127. The install pre-flight had already LOOKED there and + * found uv — it searches ~/.local/bin explicitly — so the check passed and the + * spawn then failed, which is the worst of both answers. + * + * Appended, never prepended: a directory the user deliberately put first on + * their own PATH has to keep winning, and the bundled-runtime helper above is + * the only thing entitled to the front. Only directories that exist are added, + * so this is a no-op on a machine that has none of them. + */ +export function ensureUserBinDirsOnPath(): void { + const sep = path.delimiter + const current = readPathEnv() + const present = new Set(current.split(sep).filter(Boolean)) + const missing = userBinDirs().filter((d) => { + if (present.has(d)) return false + try { + return fs.existsSync(d) + } catch { + return false + } + }) + if (!missing.length) return + writePathEnv(current ? [current, ...missing].join(sep) : missing.join(sep)) + slog(`PATH: added user bin dirs [${missing.join(", ")}]`) +} + // Smoke-test a node binary. Returns true only if `--version` exits cleanly. // Used at startup to detect a corrupt bundled node.exe (e.g. from an // interrupted download) that Windows would refuse to spawn with diff --git a/packages/launcher/src/main/bootstrap/user-path.test.ts b/packages/launcher/src/main/bootstrap/user-path.test.ts new file mode 100644 index 000000000..bc6c74427 --- /dev/null +++ b/packages/launcher/src/main/bootstrap/user-path.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { ensureUserBinDirsOnPath } from "./node-runtime" +import { readPathEnv, writePathEnv } from "../env" + +/** + * The failure this closes: OpenWorker's install command IS `uv tool install …`, + * uv's own installer puts the binary in ~/.local/bin, and a GUI-launched app + * inherits a shell-less PATH that has never heard of it. The install pre-flight + * searches ~/.local/bin and passed; the spawn it was guarding then died with + * "/bin/sh: uv: command not found" and exit 127. + */ +describe("ensureUserBinDirsOnPath", () => { + const original = readPathEnv() + let home: string + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "user-path-")) + }) + afterEach(() => { + writePathEnv(original) + fs.rmSync(home, { recursive: true, force: true }) + }) + + const localBin = (): string => path.join(os.homedir(), ".local", "bin") + + it("adds ~/.local/bin when it exists and PATH lacks it", () => { + if (!fs.existsSync(localBin())) return // nothing to assert on this machine + writePathEnv("/usr/bin:/bin") + ensureUserBinDirsOnPath() + expect(readPathEnv().split(path.delimiter)).toContain(localBin()) + }) + + it("appends rather than prepends — the user's own PATH keeps winning", () => { + if (!fs.existsSync(localBin())) return + writePathEnv("/usr/bin:/bin") + ensureUserBinDirsOnPath() + const parts = readPathEnv().split(path.delimiter) + expect(parts[0]).toBe("/usr/bin") + expect(parts.indexOf(localBin())).toBeGreaterThan(parts.indexOf("/bin")) + }) + + it("is idempotent — a second call adds nothing", () => { + writePathEnv("/usr/bin:/bin") + ensureUserBinDirsOnPath() + const once = readPathEnv() + ensureUserBinDirsOnPath() + expect(readPathEnv()).toBe(once) + }) + + it("never invents a directory that is not there", () => { + writePathEnv("/usr/bin:/bin") + ensureUserBinDirsOnPath() + for (const dir of readPathEnv().split(path.delimiter)) { + if (dir === "/usr/bin" || dir === "/bin") continue + expect(fs.existsSync(dir)).toBe(true) + } + }) +}) diff --git a/packages/launcher/src/main/index.ts b/packages/launcher/src/main/index.ts index 4410bac53..35d8b579a 100644 --- a/packages/launcher/src/main/index.ts +++ b/packages/launcher/src/main/index.ts @@ -93,6 +93,7 @@ import { applyDownloadRegion, applyProxyFromSettings, adoptSystemProxyForChildren, + proxyEnvForChildren, tuneNpmRegistry, } from "./net-config" import { hardenWebContents, openExternalSafely } from "./web-security" @@ -110,6 +111,7 @@ import { canExecuteNodeBinary, downloadNodejs, ensureBundledRuntimeFirstOnPath, + ensureUserBinDirsOnPath, extractTarball, findNpmCommand, } from "./bootstrap/node-runtime" @@ -143,6 +145,12 @@ function execFileAsync( app.setName("OpenAgents Launcher") +// Before anything can spawn: a GUI process inherits a shell-less PATH, and the +// core's installer builds its child env from ours. Without this, an agent whose +// install command is a user-installed tool (OpenWorker's `uv tool install …`) +// fails with "command not found" on a machine that has the tool. +ensureUserBinDirsOnPath() + // Stop macOS from popping the " wants to use the keychain Safe Storage" // password prompt. That entry is Chromium's OSCrypt key (shared with Electron's // safeStorage) used to encrypt cookies/local storage; its keychain ACL is bound @@ -2188,6 +2196,14 @@ function setupIPC(): void { // would look for it — Gemini opens straight into chat when it remembers an // API key, and the Google sign-in is behind its `/auth` command. const hint = agentType ? agentManager?.loginHintFor(agentType) || "" : "" + // The proxy comes FIRST so a caller's own variable would still win, but in + // practice they never overlap. It has to be written into the script because + // the macOS terminal is not our child: `osascript` hands the script to the + // already-running Terminal.app, which knows nothing of this process's + // environment. Without it a machine whose proxy lives in System Settings + // completes the browser half of a sign-in and then fails the CLI's token + // exchange with a TLS socket disconnect — the CLI went direct. + const childEnv = { ...proxyEnvForChildren(), ...(extraEnv || {}) } if (process.platform === "win32") { const { execSync: exec } = require("child_process") const home = process.env.USERPROFILE || os.homedir() @@ -2247,9 +2263,7 @@ function setupIPC(): void { "@echo off", "chcp 65001 >nul", `set "PATH=${allBins};%PATH%"`, - ...Object.entries(extraEnv || {}).map( - ([k, v]) => `set "${k}=${v}"`, - ), + ...Object.entries(childEnv).map(([k, v]) => `set "${k}=${v}"`), ...(cwd ? [`cd /d "${cwd}"`] : []), ...(hint ? [`echo ${hint.replace(/[&<>|^]/g, " ")}`, "echo."] : []), resolvedCmd, @@ -2304,9 +2318,7 @@ function setupIPC(): void { const sq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'` const lines = [ `export PATH=${sq(allBins)}:"$PATH"`, - ...Object.entries(extraEnv || {}).map( - ([k, v]) => `export ${k}=${sq(v)}`, - ), + ...Object.entries(childEnv).map(([k, v]) => `export ${k}=${sq(v)}`), ...(cwd ? [`cd ${sq(cwd)}`] : []), ...(hint ? [`echo ${sq(hint)}`, "echo"] : []), resolvedCmd, @@ -2341,6 +2353,10 @@ function setupIPC(): void { spawn(term, ["-e", resolvedCmd], { detached: true, stdio: "ignore", + // This branch used to drop `extraEnv` on the floor, so the sign-in + // overrides the other two platforms get — Gemini's workspace trust, + // CodeBuddy's site — never reached a Linux terminal at all. + env: { ...process.env, ...childEnv }, ...(cwd ? { cwd } : {}), }) return @@ -2380,9 +2396,16 @@ function setupIPC(): void { openTerminal: (cmd, type) => { // Gemini opens straight into chat when it remembers an API key, so the // sign-in runs in a directory of ours whose workspace settings ask for - // the Google flow. Everything else gets the plain terminal. + // the Google flow. const prep = type === "gemini" ? prepareGeminiSignIn() : null - runTerminal(cmd, prep?.cwd, type, prep?.env) + // …and a CLI that fronts several services (CodeBuddy's international vs + // China sites) has to sign in against the SAME one the agent will run on, + // or a sign-in the user watched succeed authenticates nothing. + const env = { + ...(prep?.env || {}), + ...(agentManager?.loginEnvFor(type) || {}), + } + runTerminal(cmd, prep?.cwd, type, env) }, emit: (ev) => { if (mainWindow && !mainWindow.isDestroyed()) @@ -2431,7 +2454,15 @@ function setupIPC(): void { // Quote the binary so a space in its path survives the shell; runTerminal // re-resolves it through the type, which also routes a `.js` bin (an npm // agent with no Windows shim) through node instead of Windows Script Host. - runTerminal(/\s/.test(binary) ? `"${binary}"` : binary, cwd, type) + // The same site pinning the sign-in terminal gets: this window IS the + // agent's own CLI, so it has to reach the service the agent is configured + // for rather than the CLI's default one. + runTerminal( + /\s/.test(binary) ? `"${binary}"` : binary, + cwd, + type, + agentManager.loginEnvFor(type), + ) }) ipcMain.handle("icons:get-dir", () => { diff --git a/packages/launcher/src/main/net-config.test.ts b/packages/launcher/src/main/net-config.test.ts index ea45d0b35..510a04436 100644 --- a/packages/launcher/src/main/net-config.test.ts +++ b/packages/launcher/src/main/net-config.test.ts @@ -13,7 +13,11 @@ vi.mock("electron", () => ({ let resolveProxyResult = "DIRECT" -import { adoptSystemProxyForChildren, firstProxyUrl } from "./net-config" +import { + adoptSystemProxyForChildren, + firstProxyUrl, + proxyEnvForChildren, +} from "./net-config" import type { Store } from "./store" const emptyStore = { get: () => undefined } as unknown as Store @@ -124,3 +128,46 @@ describe("adoptSystemProxyForChildren", () => { expect(process.env.NO_PROXY).toBeUndefined() }) }) + +/** + * The sign-in terminal is not a child of this process on macOS — `osascript` + * hands a script to the already-running Terminal.app — so the proxy has to be + * written INTO the script. Without it the browser half of a sign-in completes + * through the system proxy and the CLI's token exchange dies going direct: + * "Client network socket disconnected before secure TLS connection was + * established", which is what both `kimi login` and CodeBuddy's `/login` hit. + */ +describe("proxyEnvForChildren", () => { + it("hands over both spellings of whatever proxy won", () => { + clearProxyEnv() + process.env.HTTPS_PROXY = "http://127.0.0.1:7897" + process.env.HTTP_PROXY = "http://127.0.0.1:7897" + const env = proxyEnvForChildren() + expect(env.HTTPS_PROXY).toBe("http://127.0.0.1:7897") + expect(env.https_proxy).toBe("http://127.0.0.1:7897") + expect(env.HTTP_PROXY).toBe("http://127.0.0.1:7897") + expect(env.http_proxy).toBe("http://127.0.0.1:7897") + }) + + it("keeps the CLI's own OAuth callback off the proxy", () => { + clearProxyEnv() + process.env.HTTPS_PROXY = "http://127.0.0.1:7897" + // The sign-in completes on a local callback (CodeBuddy listens on + // 127.0.0.1); tunnelling that would break the half that works today. + expect(proxyEnvForChildren().NO_PROXY).toContain("127.0.0.1") + expect(proxyEnvForChildren().NO_PROXY).toContain("localhost") + }) + + it("respects a bypass list the user configured", () => { + clearProxyEnv() + process.env.HTTPS_PROXY = "http://127.0.0.1:7897" + process.env.NO_PROXY = "example.com" + expect(proxyEnvForChildren().NO_PROXY).toBe("example.com") + }) + + it("hands over nothing when there is no proxy", () => { + clearProxyEnv() + // A terminal that would otherwise have gone direct must keep going direct. + expect(proxyEnvForChildren()).toEqual({}) + }) +}) diff --git a/packages/launcher/src/main/net-config.ts b/packages/launcher/src/main/net-config.ts index 0e054b751..ec84a454f 100644 --- a/packages/launcher/src/main/net-config.ts +++ b/packages/launcher/src/main/net-config.ts @@ -237,6 +237,43 @@ export async function adoptSystemProxyForChildren(store: Store): Promise { slog(`child processes will use proxy ${https || http}`) } +/** + * The proxy variables a child is meant to inherit, as an explicit map. + * + * Everything the launcher spawns itself already inherits these through + * `process.env` — but a terminal window does not. On macOS the sign-in terminal + * is opened by telling the ALREADY-RUNNING Terminal.app to source a script, so + * it comes up with the user's login-shell environment and nothing of ours; a + * proxy that lives in System Settings (or that this process resolved out of it) + * is invisible there. That is the second half of the same failure the comment + * above `adoptSystemProxyForChildren` describes: the browser half of a sign-in + * goes through the tunnel and succeeds, then the CLI's token exchange goes + * direct and dies with "Client network socket disconnected before secure TLS + * connection was established". Handing these to the terminal script closes it. + * + * Reads process.env rather than the store on purpose: whichever source won — + * an explicit Settings proxy, the user's own exports, or the system proxy — has + * already been written there by the two functions around this one. + */ +export function proxyEnvForChildren(): Record { + const out: Record = {} + for (const name of ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"]) { + const lower = name.toLowerCase() + const value = process.env[name] || process.env[lower] || "" + if (!value) continue + // Both spellings: CLIs are split on which one they read. + out[name] = value + out[lower] = value + } + // A proxy with no bypass list would tunnel the CLI's own OAuth callback on + // 127.0.0.1, which is where half these sign-ins complete. + if ((out.HTTP_PROXY || out.HTTPS_PROXY) && !out.NO_PROXY) { + out.NO_PROXY = LOCAL_BYPASS + out.no_proxy = LOCAL_BYPASS + } + return out +} + export function applyProxyFromSettings(store: Store): void { const http = ((store.get("httpProxy") as string) || "").trim() const https = ((store.get("httpsProxy") as string) || "").trim() diff --git a/registry/codebuddy.json b/registry/codebuddy.json index 454c57371..56595677d 100644 --- a/registry/codebuddy.json +++ b/registry/codebuddy.json @@ -114,7 +114,7 @@ "saved_env_key": "CODEBUDDY_API_KEY", "login_command": "codebuddy", "unverifiable": true, - "not_ready_message": "Sign-in not confirmed. Set CODEBUDDY_API_KEY (press e), or run `codebuddy` and sign in with /login — there is no non-interactive login command. Auth is confirmed when you run a task.", + "not_ready_message": "Not signed in for this site. Sign in with the Login button — it opens the CLI on the site CODEBUDDY_REGION selects, where you run /login — or set CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN (press e). A session for the other site does not carry over: the CLI keeps only one, and codebuddy.ai and codebuddy.cn are separate accounts.", "auth_detected_labels": { "cli_login": "CodeBuddy sign-in detected", "api_key": "API key detected" diff --git a/workspace/backend/registry/codebuddy.json b/workspace/backend/registry/codebuddy.json index 454c57371..56595677d 100644 --- a/workspace/backend/registry/codebuddy.json +++ b/workspace/backend/registry/codebuddy.json @@ -114,7 +114,7 @@ "saved_env_key": "CODEBUDDY_API_KEY", "login_command": "codebuddy", "unverifiable": true, - "not_ready_message": "Sign-in not confirmed. Set CODEBUDDY_API_KEY (press e), or run `codebuddy` and sign in with /login — there is no non-interactive login command. Auth is confirmed when you run a task.", + "not_ready_message": "Not signed in for this site. Sign in with the Login button — it opens the CLI on the site CODEBUDDY_REGION selects, where you run /login — or set CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN (press e). A session for the other site does not carry over: the CLI keeps only one, and codebuddy.ai and codebuddy.cn are separate accounts.", "auth_detected_labels": { "cli_login": "CodeBuddy sign-in detected", "api_key": "API key detected"