diff --git a/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.md b/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.md new file mode 100644 index 000000000..716526a3c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.md @@ -0,0 +1,20 @@ +# Run the git credential helper under the CLI runtime + +Status: implemented +Translation: current +PR: https://github.com/LodyAI/Lody/pull/644 + +[中文](2026-09-12-git-helper-cli-runtime.zh.md) + +## Abstract + +GitHub HTTPS clone already has a token. Dock-launched Lody inherits the macOS GUI PATH +(`/usr/bin:/bin:/usr/sbin:/sbin`), which has no `node`. The helper was +`!node "helper.cjs"`, so git failed with `could not read Username` / +`turn_pre_prompt_failed`. Token prefetch and the broker are in-process HTTP and never +needed PATH `node`. + +Host helper is now `!"" "helper.cjs"` (both words quoted; Windows `\` +→ `/`). Electron also sets `ELECTRON_RUN_AS_NODE=1` so `Lody Helper` is not opened as a +GUI. The diagnostic probe spawns `execPath`, not `node`. Container helpers stay `!node` +because the image has `node` and the host execPath is not in the container. diff --git a/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.zh.md b/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.zh.md new file mode 100644 index 000000000..810902454 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.zh.md @@ -0,0 +1,20 @@ +# 在 CLI 自身运行时下执行 git 凭据助手 + +Status: implemented +Translation: current +PR: https://github.com/LodyAI/Lody/pull/644 + +[English](2026-09-12-git-helper-cli-runtime.md) + +## 摘要 + +GitHub HTTPS clone 本身已经拿到了令牌。从 Dock 启动的 Lody 继承的是 macOS 图形界面的 +PATH(`/usr/bin:/bin:/usr/sbin:/sbin`),其中没有 `node`。而助手命令是 +`!node "helper.cjs"`,于是 git 以 `could not read Username` 失败,对外表现为 +`turn_pre_prompt_failed`。令牌预取和 Broker 都是进程内 HTTP,从来不需要 PATH 上的 +`node`。 + +现在宿主机助手是 `!"" "helper.cjs"`(两个词都加引号;Windows 上 +`\` → `/`)。在 Electron 下还会设置 `ELECTRON_RUN_AS_NODE=1`,避免把 `Lody Helper` +当作 GUI 打开。诊断探针启动的是 `execPath` 而不是 `node`。容器内的助手仍为 `!node`, +因为镜像里有 `node`,而宿主机的 execPath 并不存在于容器中。 diff --git a/apps/cli/src/lib/git-credential-helper-script.ts b/apps/cli/src/lib/git-credential-helper-script.ts index a91ae5d78..39cdf95c3 100644 --- a/apps/cli/src/lib/git-credential-helper-script.ts +++ b/apps/cli/src/lib/git-credential-helper-script.ts @@ -338,12 +338,47 @@ export const ensureCredentialHelperScript = (repoId: RepoId): void => { const escapeForGitHelper = (value: string): string => value.replace(/"/g, '\\"'); -export const buildCredentialHelperValueForHost = (repoId: RepoId): string => { - const helperPath = escapeForGitHelper(getCredentialHelperHostPath(repoId)); - return `!node "${helperPath}"`; +/** + * Quote one word of a `credential.helper = !` value. + * + * Git runs the `!` form through a shell — `sh` on POSIX, the bundled MinGW bash on + * Windows — so every path must be quoted (installation directories contain spaces: + * `Lody Helper`, `Program Files`) and Windows separators must be forward slashes, + * because backslashes are escape characters to that shell rather than separators. + */ +const quoteForGitHelper = (value: string): string => { + const normalized = process.platform === 'win32' ? value.replace(/\\/g, '/') : value; + return `"${escapeForGitHelper(normalized)}"`; }; +/** + * Host-side helpers run under the CLI's own runtime (`process.execPath`), never a bare + * `node`. A desktop launched from the macOS Dock (or a Windows shortcut) inherits the + * GUI PATH, which usually has no `node` at all: git would then fail to start the helper, + * find no username with `GIT_TERMINAL_PROMPT=0`, and abort the clone. This mirrors what + * the CLI/MCP/watch-worker spawns already do. + */ +export const buildCredentialHelperValueForHost = (repoId: RepoId): string => + `!${quoteForGitHelper(process.execPath)} ${quoteForGitHelper( + getCredentialHelperHostPath(repoId) + )}`; + +/** + * Container helpers run inside the devcontainer image, where `node` is on PATH and the + * host's `process.execPath` does not exist. + */ export const buildCredentialHelperValueForContainer = (repoId: RepoId): string => { const helperPath = escapeForGitHelper(getCredentialHelperContainerPath(repoId)); return `!node "${helperPath}"`; }; + +/** + * Extra environment every git child (and the diagnostic helper probe) needs so that + * running `process.execPath` starts a Node process. In the packaged desktop the CLI is + * the Electron binary; without this flag the helper invocation launches a second GUI + * app instead of executing the helper script. + */ +export const buildCredentialHelperRuntimeEnv = (): Record => + process.versions.electron || process.env.ELECTRON_RUN_AS_NODE + ? { ELECTRON_RUN_AS_NODE: '1' } + : {}; diff --git a/apps/cli/src/session/session-manager.ts b/apps/cli/src/session/session-manager.ts index 1e5968ce8..bca96ba92 100644 --- a/apps/cli/src/session/session-manager.ts +++ b/apps/cli/src/session/session-manager.ts @@ -73,6 +73,7 @@ import { import type { CloudGithubTokenManager, CloudPort } from '@lody/platform'; import { isDevEnv } from '@/utils/runtime-env'; import { + buildCredentialHelperRuntimeEnv, buildCredentialHelperValueForHost, ensureCredentialHelperScript, } from '@/lib/git-credential-helper-script'; @@ -1591,6 +1592,9 @@ export class SessionManager extends EventEmitter { config.env = { ...sessionEnv, + // The helper command is `process.execPath`, so ACP git children need this flag + // to run the packaged desktop binary as Node. + ...buildCredentialHelperRuntimeEnv(), LODY_GIT_CRED_BROKER_URL: brokerUrl, LODY_GIT_CRED_BROKER_TOKEN: brokerEnv.token, // Keeps the helper's connection-refused fallback inside this workspace instead diff --git a/apps/cli/src/session/worktree/AGENTS.md b/apps/cli/src/session/worktree/AGENTS.md index eace4cd1d..2bb22ce27 100644 --- a/apps/cli/src/session/worktree/AGENTS.md +++ b/apps/cli/src/session/worktree/AGENTS.md @@ -22,6 +22,14 @@ and file responsibilities: [../README.md](../README.md). (per-workspace `broker-.json`) for the same reason. Diagnostics must probe the same broker the failing command used, or they report a misroute as the caller's workspace lacking the repo link. Regression test: `worktree-manager-broker-auth.test.ts`. +- INVARIANT: never spawn a bare `node` for the credential helper. `credential.helper` is + built from `process.execPath` with both words quoted (Windows separators normalized to + `/` for git's MinGW bash), and the diagnostic probe spawns the same runtime. A desktop + launched from the Dock or a shortcut inherits the GUI PATH, which usually has no `node`, + so a `!node` helper never starts and git fails with `terminal prompts disabled` even + though the broker is healthy. Git children and the ACP session env also carry + `ELECTRON_RUN_AS_NODE=1` when the CLI is the Electron binary + ([note](../../../../../.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.md)). ## Worktrees, branches, and setup diff --git a/apps/cli/src/session/worktree/worktree-manager.ts b/apps/cli/src/session/worktree/worktree-manager.ts index 4547beda0..5b91dbcea 100644 --- a/apps/cli/src/session/worktree/worktree-manager.ts +++ b/apps/cli/src/session/worktree/worktree-manager.ts @@ -7,6 +7,7 @@ import { Logger } from '@/utils/logger'; import { withFileLock } from '@/utils/file-lock'; import { redactUrlAuth } from '@/utils/github'; import { + buildCredentialHelperRuntimeEnv, buildCredentialHelperValueForHost, ensureCredentialHelperScript, getCredentialHelperHostPath, @@ -314,6 +315,9 @@ export class WorktreeManager { const mergedEnv: NodeJS.ProcessEnv = { ...process.env, ...env, + // The credential helper runs `process.execPath`; under Electron that binary only + // behaves as Node with this flag set. + ...buildCredentialHelperRuntimeEnv(), GIT_TERMINAL_PROMPT: '0', }; this.logger.debug(`[${this.repoId}] Running git ${args.join(' ')}`); @@ -565,6 +569,7 @@ export class WorktreeManager { const env: NodeJS.ProcessEnv = { ...process.env, ...buildBrokerAuthEnv(options.brokerAuth), + ...buildCredentialHelperRuntimeEnv(), LODY_GIT_CRED_HELPER_DEBUG: 'true', LODY_GIT_CRED_HELPER_DEBUG_FILE: debugFile, }; @@ -603,7 +608,11 @@ export class WorktreeManager { }): Promise<{ exitCode: number | null; returnedCredentials: boolean; stderrNonEmpty: boolean }> { const input = `protocol=https\nhost=${options.host}\npath=/${options.repoFullName}.git\n\n`; return await new Promise((resolve, reject) => { - const child = spawn('node', [options.helperPath, 'get'], { + // Probe with the SAME runtime git uses for the helper (`process.execPath`). + // Spawning a bare `node` would make the probe fail with ENOENT on a desktop + // launched from the Dock — exactly the failure being diagnosed — or, worse, + // succeed against an unrelated PATH Node and hide it. + const child = spawn(process.execPath, [options.helperPath, 'get'], { env: options.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, diff --git a/apps/cli/tests/worktree-manager.create.test.ts b/apps/cli/tests/worktree-manager.create.test.ts index 2f9f56904..e6aa4d81a 100644 --- a/apps/cli/tests/worktree-manager.create.test.ts +++ b/apps/cli/tests/worktree-manager.create.test.ts @@ -75,11 +75,14 @@ describe('WorktreeManager', () => { describe('git credential config', () => { it('clears inherited helpers before installing the Lody helper', () => { - expect(buildGitHubCredentialConfigArgs('!node "/tmp/lody-helper.cjs"')).toEqual([ + // The helper value names the CLI runtime explicitly and quotes both words, + // because installation paths contain spaces and the GUI PATH has no `node`. + const helperValue = '!"/Applications/Lody Helper.app/node" "/tmp/lody-helper.cjs"'; + expect(buildGitHubCredentialConfigArgs(helperValue)).toEqual([ '-c', 'credential.helper=', '-c', - 'credential.helper=!node "/tmp/lody-helper.cjs"', + `credential.helper=${helperValue}`, '-c', 'credential.useHttpPath=true', ]);