From 89d80c1db7fe5328eda9efd6823e560ee9bf19bb Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 20:17:22 +0800 Subject: [PATCH 1/3] fix(cli): run the git credential helper under the CLI runtime [risk:medium] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub repo Sessions failed to start on Macs launched from the Dock. The credential broker and token prefetch both succeeded, but `credential.helper` was `!node ""` — a PATH lookup. A GUI-launched app inherits the Dock's minimal PATH, which usually has no `node`, so the helper never ran, git found no username under `GIT_TERMINAL_PROMPT=0`, and the clone aborted with `terminal prompts disabled` → `turn_pre_prompt_failed`. Build the host helper command from `process.execPath` instead, quoting both words (installation paths contain spaces) and normalizing Windows separators to `/`, since git runs the `!` form through its bundled MinGW bash. The diagnostic probe spawns the same runtime rather than a bare `node`: on an affected machine it reported a spawn error instead of the broker verdict, and on a machine with a PATH `node` it would have succeeded against a runtime git never used. Host git children, the probe, and the ACP session environment now carry `ELECTRON_RUN_AS_NODE=1` when the CLI is the Electron binary. Container helpers keep `node`, which is on PATH inside the image. Model: claude-opus-5 --- .../2026-09-12-git-helper-cli-runtime.md | 59 ++++++++ .../lib/git-credential-helper-script.test.ts | 108 ++++++++++++++ .../src/lib/git-credential-helper-script.ts | 45 +++++- apps/cli/src/session/session-manager.test.ts | 87 +++++++++++ apps/cli/src/session/session-manager.ts | 4 + apps/cli/src/session/worktree/AGENTS.md | 8 + .../worktree-manager-broker-auth.test.ts | 140 +++++++++++++++++- .../src/session/worktree/worktree-manager.ts | 11 +- .../cli/tests/worktree-manager.create.test.ts | 7 +- 9 files changed, 455 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.md create mode 100644 apps/cli/src/lib/git-credential-helper-script.test.ts 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..f756a9499 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.md @@ -0,0 +1,59 @@ +# Run the git credential helper under the CLI runtime + +Status: implemented +Translation: pending + +## Abstract + +GitHub repo Sessions failed to start on Macs where the desktop was launched from the +Dock: token prefetch and the credential broker both succeeded, but git aborted the bare +clone with `terminal prompts disabled`, surfacing as `turn_pre_prompt_failed`. The cause +was `credential.helper = !node ""`, a PATH lookup — a GUI-launched app +inherits the Dock's minimal PATH, which usually has no `node`, so the helper never ran +and git found no username under `GIT_TERMINAL_PROMPT=0`. Lody now builds the helper +command from `process.execPath` with both words quoted, spawns the diagnostic probe with +the same runtime, and sets `ELECTRON_RUN_AS_NODE=1` on git children when the CLI is the +Electron binary. Container helpers still use `node`, which is on PATH inside the image. + +## Decision and ownership + +The CLI already resolves its own Node this way for CLI/MCP/adapter and watch-worker +spawns (`agent-client.ts`, `workspace-watch-coordinator.ts`); the host git credential +helper was the only remaining child that depended on an ambient PATH. Making it consistent +was preferred over the alternatives considered: + +- **Embedding a second Node runtime** or symlinking `~/.lody/bin/node`: adds installed + bytes, an update path, and a host-writable executable that git would execute. +- **Resolving PATH from a login shell**: the CLI already has a login-shell env helper, but + it is slow, shell-configuration dependent, and still fails for users with no `node` + installed at all — which packaged desktop users legitimately are. + +Two details are not optional. Both words are quoted because installation directories +contain spaces (`Lody Helper`, `Program Files`), and on Windows backslashes become forward +slashes: git runs the `!` form through its bundled MinGW bash, where `\` escapes rather +than separates. `ELECTRON_RUN_AS_NODE` must reach the git child, the probe, and the ACP +session environment, or `process.execPath` starts a second GUI app instead of executing +the helper script. + +The diagnostic probe (`runCredentialHelperProbe`) had the same `spawn('node', …)` bug. It +mattered twice over: on an affected machine the probe reported a spawn error rather than +the broker verdict, and on a machine that happens to have a PATH `node` the probe would +have succeeded against a runtime git never used, hiding the defect being diagnosed. The +broker routing rules in [worktree/AGENTS.md](../../../../apps/cli/src/session/worktree/AGENTS.md) +are unchanged; this is a separate failure with the same visible symptom, so a +`terminal prompts disabled` report now has two distinct causes to separate. + +## Verification + +`git-credential-helper-script.test.ts` runs real `git credential fill` against the produced +helper value, with the helper under a directory containing a space and a failing `node` +shim prepended to PATH — the Dock environment without depending on the machine's real +PATH. Reverting the fix reproduces the exact production error, +`fatal: could not read Username for 'https://…': terminal prompts disabled`. The Windows +separator rule is covered by formatting assertions, since the integration test needs a +POSIX shim and is skipped on win32; native Windows desktop startup remains unverified. + +`worktree-manager-broker-auth.test.ts` covers the host git argv, the probe's spawn command, +and `ELECTRON_RUN_AS_NODE` propagation; `session-manager.test.ts` covers `GIT_CONFIG_VALUE_1` +and the same flag on the ACP session environment. Every new assertion was ablated +individually against the pre-fix code and fails without it. Run on macOS with Vitest 3.2.4. diff --git a/apps/cli/src/lib/git-credential-helper-script.test.ts b/apps/cli/src/lib/git-credential-helper-script.test.ts new file mode 100644 index 000000000..6b4f67097 --- /dev/null +++ b/apps/cli/src/lib/git-credential-helper-script.test.ts @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + buildCredentialHelperRuntimeEnv, + formatCredentialHelperCommand, +} from './git-credential-helper-script'; + +const HELPER_SOURCE = `process.stdin.resume(); +process.stdout.write('username=x-access-token\\npassword=ghs_managed\\n\\n'); +`; + +/** Absolute git path, so the child can run with a PATH that resolves nothing else. */ +const resolveGitBinary = (): string => + execFileSync(process.platform === 'win32' ? 'where' : 'which', ['git'], { encoding: 'utf8' }) + .split(/\r?\n/)[0] + .trim(); + +describe('host git credential helper command', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(path.join(os.tmpdir(), 'lody-cred-helper-')); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + // The desktop is frequently launched from the Dock/Start menu, where the GUI + // PATH has no `node` at all. Shadowing `node` with a failing shim reproduces + // that environment without depending on the machine's real PATH. + it.skipIf(process.platform === 'win32')( + 'lets git fill credentials when no usable `node` is on PATH', + () => { + // Installation directories really do contain spaces ("Lody Helper", + // "Program Files"), so the produced command must survive the shell. + const helperDir = path.join(testDir, 'Lody Helper', 'resources'); + mkdirSync(helperDir, { recursive: true }); + const helperPath = path.join(helperDir, 'lody-git-credential-helper.cjs'); + writeFileSync(helperPath, HELPER_SOURCE, 'utf8'); + + const shimDir = path.join(testDir, 'no-node-bin'); + mkdirSync(shimDir, { recursive: true }); + writeFileSync(path.join(shimDir, 'node'), '#!/bin/sh\nexit 127\n', { mode: 0o755 }); + + const output = execFileSync( + resolveGitBinary(), + [ + '-c', + 'credential.helper=', + '-c', + `credential.helper=${formatCredentialHelperCommand(process.execPath, helperPath)}`, + 'credential', + 'fill', + ], + { + input: 'protocol=https\nhost=github.com\npath=owner/repo.git\n\n', + encoding: 'utf8', + env: { + ...process.env, + PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`, + GIT_TERMINAL_PROMPT: '0', + ...buildCredentialHelperRuntimeEnv(), + }, + } + ); + + expect(output).toContain('username=x-access-token'); + expect(output).toContain('password=ghs_managed'); + } + ); + + it('quotes both words and keeps embedded quotes escaped', () => { + expect( + formatCredentialHelperCommand('/opt/Lody Helper/node', '/tmp/a b/helper.cjs', 'darwin') + ).toBe('!"/opt/Lody Helper/node" "/tmp/a b/helper.cjs"'); + expect(formatCredentialHelperCommand('/usr/bin/node', '/tmp/we"ird/helper.cjs', 'linux')).toBe( + '!"/usr/bin/node" "/tmp/we\\"ird/helper.cjs"' + ); + }); + + // Git runs the `!` form through its bundled MinGW bash, where a backslash is an + // escape character rather than a path separator. + it('rewrites Windows separators as forward slashes', () => { + expect( + formatCredentialHelperCommand( + 'C:\\Program Files\\Lody\\Lody.exe', + 'C:\\Users\\dev\\.lody\\repos\\r\\helper.cjs', + 'win32' + ) + ).toBe('!"C:/Program Files/Lody/Lody.exe" "C:/Users/dev/.lody/repos/r/helper.cjs"'); + }); +}); + +describe('buildCredentialHelperRuntimeEnv', () => { + it('propagates the Electron-as-Node flag to git children', () => { + expect(buildCredentialHelperRuntimeEnv({ ELECTRON_RUN_AS_NODE: '1' })).toEqual({ + ELECTRON_RUN_AS_NODE: '1', + }); + }); + + it('adds nothing under a plain Node runtime', () => { + expect(buildCredentialHelperRuntimeEnv({})).toEqual({}); + }); +}); diff --git a/apps/cli/src/lib/git-credential-helper-script.ts b/apps/cli/src/lib/git-credential-helper-script.ts index a91ae5d78..6af0fe3b4 100644 --- a/apps/cli/src/lib/git-credential-helper-script.ts +++ b/apps/cli/src/lib/git-credential-helper-script.ts @@ -338,12 +338,51 @@ 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, platform: NodeJS.Platform): string => { + const normalized = platform === 'win32' ? value.replace(/\\/g, '/') : value; + return `"${escapeForGitHelper(normalized)}"`; }; +export const formatCredentialHelperCommand = ( + nodePath: string, + helperPath: string, + platform: NodeJS.Platform = process.platform +): string => `!${quoteForGitHelper(nodePath, platform)} ${quoteForGitHelper(helperPath, platform)}`; + +/** + * 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 => + formatCredentialHelperCommand(process.execPath, 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 = ( + source: NodeJS.ProcessEnv = process.env +): Record => + process.versions.electron || source.ELECTRON_RUN_AS_NODE ? { ELECTRON_RUN_AS_NODE: '1' } : {}; diff --git a/apps/cli/src/session/session-manager.test.ts b/apps/cli/src/session/session-manager.test.ts index 90e32cc9f..c543d9a40 100644 --- a/apps/cli/src/session/session-manager.test.ts +++ b/apps/cli/src/session/session-manager.test.ts @@ -263,6 +263,93 @@ describe('SessionManager cleanup phases', () => { }); }); +// Git runs a `!` credential helper through a shell, so Windows paths reach it with +// forward slashes. +const toShellPath = (value: string): string => + process.platform === 'win32' ? value.replace(/\\/g, '/') : value; + +describe('SessionManager GitHub session git credentials', () => { + let tempDataDir: string; + + beforeEach(() => { + tempDataDir = mkdtempSync(path.join(os.tmpdir(), 'lody-session-git-cred-')); + vi.stubEnv('LODY_DATA_DIR', tempDataDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempDataDir, { recursive: true, force: true }); + }); + + const prepareGitHubSessionEnv = async (): Promise> => { + const manager = new SessionManager( + createLogger(), + 'token', + 'machine-1' as MachineId, + 'workspace-1' as WorkspaceId, + createWorkspaceDocument(new Map()), + { + sessionSandboxFactory: async () => createNoopSessionSandbox(), + cloudPort: createTestCloudPort(), + } + ); + const internals = manager as unknown as { + githubTokenManager: unknown; + gitCredentialBroker: unknown; + prepareGitHubRepoSessionConfig(config: SessionConfig): Promise; + }; + // The local cloud port exposes no GitHub tokens, so stand in for the hosted + // token manager and an already-started broker. + internals.githubTokenManager = { + retainRepoOwner: vi.fn(), + getAppTokenForRepo: vi.fn(async () => 'ghs_app'), + getWriteTokenInfoForRepo: vi.fn(async () => ({ token: 'ghs_write', tokenSource: 'app' })), + startAutoRefresh: vi.fn(), + }; + internals.gitCredentialBroker = { + ensureStarted: vi.fn(async () => ({ + url: 'http://127.0.0.1:33215', + port: 33215, + token: 'broker-token', + })), + activateSessionContext: vi.fn(() => 'context-token'), + getStateFilePath: vi.fn(() => path.join(tempDataDir, 'broker-workspace-1.json')), + }; + + const config = createSessionConfig({ + sessionId: 'github-session' as SessionId, + githubRepo: 'owner/repo', + env: {}, + }); + await internals.prepareGitHubRepoSessionConfig(config); + return (config.env ?? {}) as Record; + }; + + // ACP git children get the helper through GIT_CONFIG_VALUE_1. A `!node` helper + // cannot start when the desktop was launched from the Dock without node on PATH. + it('installs the helper under the CLI runtime, not a PATH `node`', async () => { + const env = await prepareGitHubSessionEnv(); + + const helperPath = path.join( + tempDataDir, + 'repos', + 'github---owner---repo', + 'lody-git-credential-helper.cjs' + ); + expect(env.GIT_CONFIG_KEY_1).toBe('credential.helper'); + expect(env.GIT_CONFIG_VALUE_1).toBe( + `!"${toShellPath(process.execPath)}" "${toShellPath(helperPath)}"` + ); + expect(existsSync(helperPath)).toBe(true); + }); + + it('forces ELECTRON_RUN_AS_NODE for ACP git children under Electron', async () => { + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1'); + + expect((await prepareGitHubSessionEnv()).ELECTRON_RUN_AS_NODE).toBe('1'); + }); +}); + describe('SessionManager child session workdir resolution', () => { let tempHome: string; 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-broker-auth.test.ts b/apps/cli/src/session/worktree/worktree-manager-broker-auth.test.ts index c5987b8cb..9b4af2379 100644 --- a/apps/cli/src/session/worktree/worktree-manager-broker-auth.test.ts +++ b/apps/cli/src/session/worktree/worktree-manager-broker-auth.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events'; -import { Readable } from 'node:stream'; +import { Readable, Writable } from 'node:stream'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -19,15 +19,24 @@ vi.mock('@/utils/file-lock', () => ({ * `stdout` is scripted per invocation so callers that parse output (fetchspec * probing, rev-parse) take their normal branches without a real repository. */ -function makeChild(stdout: string) { +function makeChild(stdout: string, options?: { stderr?: string; exitCode?: number }) { const child = new EventEmitter() as EventEmitter & { stdout: Readable; stderr: Readable; + stdin: Writable; }; child.stdout = Readable.from([stdout]); - child.stderr = Readable.from([]); - // Emit close after the streams have been consumed by the caller's listeners. - queueMicrotask(() => queueMicrotask(() => child.emit('close', 0))); + child.stderr = Readable.from([options?.stderr ?? '']); + // The credential-helper probe writes its request to stdin before waiting. + child.stdin = new Writable({ write: (_chunk, _encoding, done) => done() }); + // Emit close only once both streams have been fully delivered, or callers that + // classify a failure from stderr would see an empty message. + let pending = 2; + const settle = () => { + if (--pending === 0) child.emit('close', options?.exitCode ?? 0); + }; + child.stdout.once('end', settle); + child.stderr.once('end', settle); return child; } @@ -48,8 +57,8 @@ function createLogger(): Logger { const REPO_ID = 'github---owner---repo' as RepoId; const REPO_URL = 'https://github.com/owner/repo.git'; -/** Env of the git invocation whose argv contains `verb`. */ -function envOfGitCall(verb: string): NodeJS.ProcessEnv { +/** The spawn call whose argv contains `verb`. */ +function gitCall(verb: string): [string, string[], { env: NodeJS.ProcessEnv }] { const call = spawnMock.mock.calls.find(([, args]) => (args as string[]).includes(verb)); if (!call) { throw new Error( @@ -58,7 +67,12 @@ function envOfGitCall(verb: string): NodeJS.ProcessEnv { .join(' | ')}` ); } - return (call[2] as { env: NodeJS.ProcessEnv }).env; + return call as [string, string[], { env: NodeJS.ProcessEnv }]; +} + +/** Env of the git invocation whose argv contains `verb`. */ +function envOfGitCall(verb: string): NodeJS.ProcessEnv { + return gitCall(verb)[2].env; } describe('WorktreeManager host git credential broker routing', () => { @@ -135,3 +149,113 @@ describe('WorktreeManager host git credential broker routing', () => { expect(env.LODY_GIT_CRED_BROKER_TOKEN).toBe('ambient-token'); }); }); + +describe('WorktreeManager host git credential helper runtime', () => { + let dataDir: string; + let previousDataDir: string | undefined; + let previousRunAsNode: string | undefined; + + beforeEach(() => { + spawnMock.mockReset(); + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('--get-all')) { + return makeChild('+refs/heads/*:refs/remotes/origin/*\n'); + } + if (args.includes('rev-parse')) return makeChild('deadbeef\n'); + return makeChild(''); + }); + + previousDataDir = process.env.LODY_DATA_DIR; + previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + delete process.env.ELECTRON_RUN_AS_NODE; + dataDir = mkdtempSync(path.join(os.tmpdir(), 'lody-helper-runtime-')); + process.env.LODY_DATA_DIR = dataDir; + mkdirSync(path.join(dataDir, 'repos', REPO_ID, 'bare.git'), { recursive: true }); + }); + + afterEach(() => { + if (previousDataDir === undefined) delete process.env.LODY_DATA_DIR; + else process.env.LODY_DATA_DIR = previousDataDir; + if (previousRunAsNode === undefined) delete process.env.ELECTRON_RUN_AS_NODE; + else process.env.ELECTRON_RUN_AS_NODE = previousRunAsNode; + rmSync(dataDir, { recursive: true, force: true }); + }); + + async function newManager() { + const { WorktreeManager } = await import('./worktree-manager'); + return new WorktreeManager({ + repoId: REPO_ID, + source: { kind: 'github', repoUrl: REPO_URL }, + logger: createLogger(), + }); + } + + // A desktop launched from the Dock has no `node` on its GUI PATH, so a `!node` + // helper never starts and git aborts with "terminal prompts disabled". + it('points credential.helper at the CLI runtime instead of a PATH lookup', async () => { + const manager = await newManager(); + await manager.ensureRepo(); + + const helperArg = gitCall('fetch')[1].find( + (arg) => arg.startsWith('credential.helper=') && arg.length > 'credential.helper='.length + ); + const helperPath = path.join( + dataDir, + 'repos', + REPO_ID, + 'lody-git-credential-helper.cjs' + ); + const toShellPath = (value: string) => + process.platform === 'win32' ? value.replace(/\\/g, '/') : value; + expect(helperArg).toBe( + `credential.helper=!"${toShellPath(process.execPath)}" "${toShellPath(helperPath)}"` + ); + expect(helperArg).not.toContain('!node '); + }); + + // The packaged desktop CLI *is* the Electron binary: without the flag, git + // running `process.execPath` would launch a second GUI app. + it('forces ELECTRON_RUN_AS_NODE on git children when running under Electron', async () => { + process.env.ELECTRON_RUN_AS_NODE = '1'; + + const manager = await newManager(); + await manager.ensureRepo(); + + expect(envOfGitCall('fetch').ELECTRON_RUN_AS_NODE).toBe('1'); + }); + + it('leaves ELECTRON_RUN_AS_NODE unset under a plain Node CLI', async () => { + const manager = await newManager(); + await manager.ensureRepo(); + + expect(envOfGitCall('fetch').ELECTRON_RUN_AS_NODE).toBeUndefined(); + }); + + it('probes the failing helper with the same runtime git uses', async () => { + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('--get-all')) { + return makeChild('+refs/heads/*:refs/remotes/origin/*\n'); + } + if (args.includes('fetch')) { + return makeChild('', { + stderr: + "fatal: could not read Username for 'https://github.com': terminal prompts disabled\n", + exitCode: 128, + }); + } + if (args.includes('rev-parse')) return makeChild('deadbeef\n'); + return makeChild(''); + }); + + const manager = await newManager(); + await manager.ensureRepo(); + + const probeCall = spawnMock.mock.calls.find(([, args]) => (args as string[]).includes('get')); + expect(probeCall).toBeDefined(); + expect(probeCall?.[0]).toBe(process.execPath); + expect(probeCall?.[1]).toEqual([ + path.join(dataDir, 'repos', REPO_ID, 'lody-git-credential-helper.cjs'), + 'get', + ]); + }); +}); 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', ]); From 07c291bb9b084f172ffbea14a2359a3a389db114 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 20:44:59 +0800 Subject: [PATCH 2/3] docs: translate the git credential helper runtime note [risk:low] Add the Chinese counterpart of the CLI-runtime credential helper note and mark both sides `Translation: current` with counterpart links. Same decision, not a rewrite; no code or Spec changes. Model: claude-opus-5 --- .../2026-09-12-git-helper-cli-runtime.md | 4 +- .../2026-09-12-git-helper-cli-runtime.zh.md | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.zh.md 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 index f756a9499..97d18b7c0 100644 --- 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 @@ -1,7 +1,9 @@ # Run the git credential helper under the CLI runtime Status: implemented -Translation: pending +Translation: current + +[中文](2026-09-12-git-helper-cli-runtime.zh.md) ## Abstract 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..5da4ab1ee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-12-git-helper-cli-runtime.zh.md @@ -0,0 +1,57 @@ +# 在 CLI 自身运行时下执行 git 凭据助手 + +Status: implemented +Translation: current + +[English](2026-09-12-git-helper-cli-runtime.md) + +## 摘要 + +在从 Dock 启动桌面端的 Mac 上,GitHub 仓库 Session 无法启动:令牌预取和凭据 +Broker 都成功,但 git 以 `terminal prompts disabled` 中止了 bare clone,对外表现 +为 `turn_pre_prompt_failed`。根因是 `credential.helper = !node ""` 这一 +依赖 PATH 查找的写法——从图形界面启动的应用继承的是 Dock 的最小 PATH,其中通常 +没有 `node`,于是助手根本没有运行,git 在 `GIT_TERMINAL_PROMPT=0` 下也就拿不到 +用户名。现在 Lody 改为用 `process.execPath` 构造助手命令并对两个词都加引号,诊断 +探针也以同一运行时启动,并在 CLI 就是 Electron 可执行文件时为 git 子进程设置 +`ELECTRON_RUN_AS_NODE=1`。容器内的助手仍使用 `node`,因为镜像里的 PATH 上有它。 + +## 决策与归属 + +CLI 在 CLI/MCP/适配器以及 watch worker 的子进程启动中早已用这种方式解析自身的 +Node(`agent-client.ts`、`workspace-watch-coordinator.ts`);宿主机 git 凭据助手是 +最后一个仍依赖环境 PATH 的子进程。让它与其余部分保持一致,优于考虑过的两个替代 +方案: + +- **内嵌第二份 Node 运行时**或在 `~/.lody/bin/node` 建符号链接:增加安装体积和 + 一条更新路径,还会引入一个宿主机可写、且会被 git 执行的可执行文件。 +- **从登录 shell 解析 PATH**:CLI 确实已有登录 shell 环境的辅助能力,但它慢、依赖 + 用户的 shell 配置,而且对根本没有安装 `node` 的用户仍然失败——而打包桌面端的 + 用户本来就可能属于这一类。 + +有两个细节不是可选项。两个词都加引号,是因为安装目录中含有空格(`Lody Helper`、 +`Program Files`);在 Windows 上反斜杠要改成正斜杠,因为 git 通过随附的 MinGW bash +执行 `!` 形式的命令,其中 `\` 是转义符而非路径分隔符。`ELECTRON_RUN_AS_NODE` 必须 +同时到达 git 子进程、探针和 ACP Session 环境,否则 `process.execPath` 会再启动一个 +GUI 应用,而不是执行助手脚本。 + +诊断探针(`runCredentialHelperProbe`)存在同样的 `spawn('node', …)` 缺陷,其影响是 +双重的:在受影响的机器上,探针报告的是启动进程失败而不是 Broker 的判定结果;而在 +恰好 PATH 上有 `node` 的机器上,探针会用一个 git 从未使用过的运行时取得成功,反而 +掩盖了正在被诊断的缺陷。[worktree/AGENTS.md](../../../../apps/cli/src/session/worktree/AGENTS.md) +中的 Broker 路由规则没有改变;这是一个表征相同、原因不同的故障,因此今后看到 +`terminal prompts disabled` 需要区分两种不同的成因。 + +## 验证 + +`git-credential-helper-script.test.ts` 用生成出来的助手命令实际运行 +`git credential fill`:助手放在含空格的目录下,并在 PATH 前置一个必然失败的 `node` +垫片——以此复现 Dock 的环境,而不依赖本机真实的 PATH。回滚该修复会重现线上完全 +一致的报错 `fatal: could not read Username for 'https://…': terminal prompts disabled`。 +Windows 的分隔符规则由字符串格式断言覆盖,因为该集成测试需要 POSIX 垫片、在 win32 +上跳过;Windows 桌面端的原生启动仍未验证。 + +`worktree-manager-broker-auth.test.ts` 覆盖宿主机 git 的参数、探针的启动命令以及 +`ELECTRON_RUN_AS_NODE` 的传递;`session-manager.test.ts` 覆盖 `GIT_CONFIG_VALUE_1` +以及 ACP Session 环境上的同一个标志。每条新增断言都针对修复前的代码单独做过消融, +缺少修复即失败。测试在 macOS 上以 Vitest 3.2.4 运行。 From 3ea7bf63e5c013dc7b71cb423ade35df7285e5c8 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 21:55:35 +0800 Subject: [PATCH 3/3] docs: shrink the git credential helper note and drop its tests [risk:low] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper-path swap does not need dedicated coverage: delete the new `git-credential-helper-script.test.ts` and revert the expansions in `worktree-manager-broker-auth.test.ts` and `session-manager.test.ts`. The only remaining test edit updates the existing `!node "…helper.cjs"` literal in `worktree-manager.create.test.ts` so the suite still passes. `formatCredentialHelperCommand` and the injectable env/platform parameters existed only for those tests, so they fold back into the two callers. Both notes are cut to the problem and the fix. Model: claude-opus-5 --- .../2026-09-12-git-helper-cli-runtime.md | 61 ++------ .../2026-09-12-git-helper-cli-runtime.zh.md | 57 ++----- .../lib/git-credential-helper-script.test.ts | 108 -------------- .../src/lib/git-credential-helper-script.ts | 22 ++- apps/cli/src/session/session-manager.test.ts | 87 ----------- .../worktree-manager-broker-auth.test.ts | 140 +----------------- 6 files changed, 37 insertions(+), 438 deletions(-) delete mode 100644 apps/cli/src/lib/git-credential-helper-script.test.ts 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 index 97d18b7c0..716526a3c 100644 --- 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 @@ -2,60 +2,19 @@ Status: implemented Translation: current +PR: https://github.com/LodyAI/Lody/pull/644 [中文](2026-09-12-git-helper-cli-runtime.zh.md) ## Abstract -GitHub repo Sessions failed to start on Macs where the desktop was launched from the -Dock: token prefetch and the credential broker both succeeded, but git aborted the bare -clone with `terminal prompts disabled`, surfacing as `turn_pre_prompt_failed`. The cause -was `credential.helper = !node ""`, a PATH lookup — a GUI-launched app -inherits the Dock's minimal PATH, which usually has no `node`, so the helper never ran -and git found no username under `GIT_TERMINAL_PROMPT=0`. Lody now builds the helper -command from `process.execPath` with both words quoted, spawns the diagnostic probe with -the same runtime, and sets `ELECTRON_RUN_AS_NODE=1` on git children when the CLI is the -Electron binary. Container helpers still use `node`, which is on PATH inside the image. +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`. -## Decision and ownership - -The CLI already resolves its own Node this way for CLI/MCP/adapter and watch-worker -spawns (`agent-client.ts`, `workspace-watch-coordinator.ts`); the host git credential -helper was the only remaining child that depended on an ambient PATH. Making it consistent -was preferred over the alternatives considered: - -- **Embedding a second Node runtime** or symlinking `~/.lody/bin/node`: adds installed - bytes, an update path, and a host-writable executable that git would execute. -- **Resolving PATH from a login shell**: the CLI already has a login-shell env helper, but - it is slow, shell-configuration dependent, and still fails for users with no `node` - installed at all — which packaged desktop users legitimately are. - -Two details are not optional. Both words are quoted because installation directories -contain spaces (`Lody Helper`, `Program Files`), and on Windows backslashes become forward -slashes: git runs the `!` form through its bundled MinGW bash, where `\` escapes rather -than separates. `ELECTRON_RUN_AS_NODE` must reach the git child, the probe, and the ACP -session environment, or `process.execPath` starts a second GUI app instead of executing -the helper script. - -The diagnostic probe (`runCredentialHelperProbe`) had the same `spawn('node', …)` bug. It -mattered twice over: on an affected machine the probe reported a spawn error rather than -the broker verdict, and on a machine that happens to have a PATH `node` the probe would -have succeeded against a runtime git never used, hiding the defect being diagnosed. The -broker routing rules in [worktree/AGENTS.md](../../../../apps/cli/src/session/worktree/AGENTS.md) -are unchanged; this is a separate failure with the same visible symptom, so a -`terminal prompts disabled` report now has two distinct causes to separate. - -## Verification - -`git-credential-helper-script.test.ts` runs real `git credential fill` against the produced -helper value, with the helper under a directory containing a space and a failing `node` -shim prepended to PATH — the Dock environment without depending on the machine's real -PATH. Reverting the fix reproduces the exact production error, -`fatal: could not read Username for 'https://…': terminal prompts disabled`. The Windows -separator rule is covered by formatting assertions, since the integration test needs a -POSIX shim and is skipped on win32; native Windows desktop startup remains unverified. - -`worktree-manager-broker-auth.test.ts` covers the host git argv, the probe's spawn command, -and `ELECTRON_RUN_AS_NODE` propagation; `session-manager.test.ts` covers `GIT_CONFIG_VALUE_1` -and the same flag on the ACP session environment. Every new assertion was ablated -individually against the pre-fix code and fails without it. Run on macOS with Vitest 3.2.4. +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 index 5da4ab1ee..810902454 100644 --- 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 @@ -2,56 +2,19 @@ Status: implemented Translation: current +PR: https://github.com/LodyAI/Lody/pull/644 [English](2026-09-12-git-helper-cli-runtime.md) ## 摘要 -在从 Dock 启动桌面端的 Mac 上,GitHub 仓库 Session 无法启动:令牌预取和凭据 -Broker 都成功,但 git 以 `terminal prompts disabled` 中止了 bare clone,对外表现 -为 `turn_pre_prompt_failed`。根因是 `credential.helper = !node ""` 这一 -依赖 PATH 查找的写法——从图形界面启动的应用继承的是 Dock 的最小 PATH,其中通常 -没有 `node`,于是助手根本没有运行,git 在 `GIT_TERMINAL_PROMPT=0` 下也就拿不到 -用户名。现在 Lody 改为用 `process.execPath` 构造助手命令并对两个词都加引号,诊断 -探针也以同一运行时启动,并在 CLI 就是 Electron 可执行文件时为 git 子进程设置 -`ELECTRON_RUN_AS_NODE=1`。容器内的助手仍使用 `node`,因为镜像里的 PATH 上有它。 +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`。 -## 决策与归属 - -CLI 在 CLI/MCP/适配器以及 watch worker 的子进程启动中早已用这种方式解析自身的 -Node(`agent-client.ts`、`workspace-watch-coordinator.ts`);宿主机 git 凭据助手是 -最后一个仍依赖环境 PATH 的子进程。让它与其余部分保持一致,优于考虑过的两个替代 -方案: - -- **内嵌第二份 Node 运行时**或在 `~/.lody/bin/node` 建符号链接:增加安装体积和 - 一条更新路径,还会引入一个宿主机可写、且会被 git 执行的可执行文件。 -- **从登录 shell 解析 PATH**:CLI 确实已有登录 shell 环境的辅助能力,但它慢、依赖 - 用户的 shell 配置,而且对根本没有安装 `node` 的用户仍然失败——而打包桌面端的 - 用户本来就可能属于这一类。 - -有两个细节不是可选项。两个词都加引号,是因为安装目录中含有空格(`Lody Helper`、 -`Program Files`);在 Windows 上反斜杠要改成正斜杠,因为 git 通过随附的 MinGW bash -执行 `!` 形式的命令,其中 `\` 是转义符而非路径分隔符。`ELECTRON_RUN_AS_NODE` 必须 -同时到达 git 子进程、探针和 ACP Session 环境,否则 `process.execPath` 会再启动一个 -GUI 应用,而不是执行助手脚本。 - -诊断探针(`runCredentialHelperProbe`)存在同样的 `spawn('node', …)` 缺陷,其影响是 -双重的:在受影响的机器上,探针报告的是启动进程失败而不是 Broker 的判定结果;而在 -恰好 PATH 上有 `node` 的机器上,探针会用一个 git 从未使用过的运行时取得成功,反而 -掩盖了正在被诊断的缺陷。[worktree/AGENTS.md](../../../../apps/cli/src/session/worktree/AGENTS.md) -中的 Broker 路由规则没有改变;这是一个表征相同、原因不同的故障,因此今后看到 -`terminal prompts disabled` 需要区分两种不同的成因。 - -## 验证 - -`git-credential-helper-script.test.ts` 用生成出来的助手命令实际运行 -`git credential fill`:助手放在含空格的目录下,并在 PATH 前置一个必然失败的 `node` -垫片——以此复现 Dock 的环境,而不依赖本机真实的 PATH。回滚该修复会重现线上完全 -一致的报错 `fatal: could not read Username for 'https://…': terminal prompts disabled`。 -Windows 的分隔符规则由字符串格式断言覆盖,因为该集成测试需要 POSIX 垫片、在 win32 -上跳过;Windows 桌面端的原生启动仍未验证。 - -`worktree-manager-broker-auth.test.ts` 覆盖宿主机 git 的参数、探针的启动命令以及 -`ELECTRON_RUN_AS_NODE` 的传递;`session-manager.test.ts` 覆盖 `GIT_CONFIG_VALUE_1` -以及 ACP Session 环境上的同一个标志。每条新增断言都针对修复前的代码单独做过消融, -缺少修复即失败。测试在 macOS 上以 Vitest 3.2.4 运行。 +现在宿主机助手是 `!"" "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.test.ts b/apps/cli/src/lib/git-credential-helper-script.test.ts deleted file mode 100644 index 6b4f67097..000000000 --- a/apps/cli/src/lib/git-credential-helper-script.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - buildCredentialHelperRuntimeEnv, - formatCredentialHelperCommand, -} from './git-credential-helper-script'; - -const HELPER_SOURCE = `process.stdin.resume(); -process.stdout.write('username=x-access-token\\npassword=ghs_managed\\n\\n'); -`; - -/** Absolute git path, so the child can run with a PATH that resolves nothing else. */ -const resolveGitBinary = (): string => - execFileSync(process.platform === 'win32' ? 'where' : 'which', ['git'], { encoding: 'utf8' }) - .split(/\r?\n/)[0] - .trim(); - -describe('host git credential helper command', () => { - let testDir: string; - - beforeEach(() => { - testDir = mkdtempSync(path.join(os.tmpdir(), 'lody-cred-helper-')); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - // The desktop is frequently launched from the Dock/Start menu, where the GUI - // PATH has no `node` at all. Shadowing `node` with a failing shim reproduces - // that environment without depending on the machine's real PATH. - it.skipIf(process.platform === 'win32')( - 'lets git fill credentials when no usable `node` is on PATH', - () => { - // Installation directories really do contain spaces ("Lody Helper", - // "Program Files"), so the produced command must survive the shell. - const helperDir = path.join(testDir, 'Lody Helper', 'resources'); - mkdirSync(helperDir, { recursive: true }); - const helperPath = path.join(helperDir, 'lody-git-credential-helper.cjs'); - writeFileSync(helperPath, HELPER_SOURCE, 'utf8'); - - const shimDir = path.join(testDir, 'no-node-bin'); - mkdirSync(shimDir, { recursive: true }); - writeFileSync(path.join(shimDir, 'node'), '#!/bin/sh\nexit 127\n', { mode: 0o755 }); - - const output = execFileSync( - resolveGitBinary(), - [ - '-c', - 'credential.helper=', - '-c', - `credential.helper=${formatCredentialHelperCommand(process.execPath, helperPath)}`, - 'credential', - 'fill', - ], - { - input: 'protocol=https\nhost=github.com\npath=owner/repo.git\n\n', - encoding: 'utf8', - env: { - ...process.env, - PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`, - GIT_TERMINAL_PROMPT: '0', - ...buildCredentialHelperRuntimeEnv(), - }, - } - ); - - expect(output).toContain('username=x-access-token'); - expect(output).toContain('password=ghs_managed'); - } - ); - - it('quotes both words and keeps embedded quotes escaped', () => { - expect( - formatCredentialHelperCommand('/opt/Lody Helper/node', '/tmp/a b/helper.cjs', 'darwin') - ).toBe('!"/opt/Lody Helper/node" "/tmp/a b/helper.cjs"'); - expect(formatCredentialHelperCommand('/usr/bin/node', '/tmp/we"ird/helper.cjs', 'linux')).toBe( - '!"/usr/bin/node" "/tmp/we\\"ird/helper.cjs"' - ); - }); - - // Git runs the `!` form through its bundled MinGW bash, where a backslash is an - // escape character rather than a path separator. - it('rewrites Windows separators as forward slashes', () => { - expect( - formatCredentialHelperCommand( - 'C:\\Program Files\\Lody\\Lody.exe', - 'C:\\Users\\dev\\.lody\\repos\\r\\helper.cjs', - 'win32' - ) - ).toBe('!"C:/Program Files/Lody/Lody.exe" "C:/Users/dev/.lody/repos/r/helper.cjs"'); - }); -}); - -describe('buildCredentialHelperRuntimeEnv', () => { - it('propagates the Electron-as-Node flag to git children', () => { - expect(buildCredentialHelperRuntimeEnv({ ELECTRON_RUN_AS_NODE: '1' })).toEqual({ - ELECTRON_RUN_AS_NODE: '1', - }); - }); - - it('adds nothing under a plain Node runtime', () => { - expect(buildCredentialHelperRuntimeEnv({})).toEqual({}); - }); -}); diff --git a/apps/cli/src/lib/git-credential-helper-script.ts b/apps/cli/src/lib/git-credential-helper-script.ts index 6af0fe3b4..39cdf95c3 100644 --- a/apps/cli/src/lib/git-credential-helper-script.ts +++ b/apps/cli/src/lib/git-credential-helper-script.ts @@ -346,17 +346,11 @@ const escapeForGitHelper = (value: string): string => value.replace(/"/g, '\\"') * `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, platform: NodeJS.Platform): string => { - const normalized = platform === 'win32' ? value.replace(/\\/g, '/') : value; +const quoteForGitHelper = (value: string): string => { + const normalized = process.platform === 'win32' ? value.replace(/\\/g, '/') : value; return `"${escapeForGitHelper(normalized)}"`; }; -export const formatCredentialHelperCommand = ( - nodePath: string, - helperPath: string, - platform: NodeJS.Platform = process.platform -): string => `!${quoteForGitHelper(nodePath, platform)} ${quoteForGitHelper(helperPath, platform)}`; - /** * 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 @@ -365,7 +359,9 @@ export const formatCredentialHelperCommand = ( * the CLI/MCP/watch-worker spawns already do. */ export const buildCredentialHelperValueForHost = (repoId: RepoId): string => - formatCredentialHelperCommand(process.execPath, getCredentialHelperHostPath(repoId)); + `!${quoteForGitHelper(process.execPath)} ${quoteForGitHelper( + getCredentialHelperHostPath(repoId) + )}`; /** * Container helpers run inside the devcontainer image, where `node` is on PATH and the @@ -382,7 +378,7 @@ export const buildCredentialHelperValueForContainer = (repoId: RepoId): string = * the Electron binary; without this flag the helper invocation launches a second GUI * app instead of executing the helper script. */ -export const buildCredentialHelperRuntimeEnv = ( - source: NodeJS.ProcessEnv = process.env -): Record => - process.versions.electron || source.ELECTRON_RUN_AS_NODE ? { ELECTRON_RUN_AS_NODE: '1' } : {}; +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.test.ts b/apps/cli/src/session/session-manager.test.ts index c543d9a40..90e32cc9f 100644 --- a/apps/cli/src/session/session-manager.test.ts +++ b/apps/cli/src/session/session-manager.test.ts @@ -263,93 +263,6 @@ describe('SessionManager cleanup phases', () => { }); }); -// Git runs a `!` credential helper through a shell, so Windows paths reach it with -// forward slashes. -const toShellPath = (value: string): string => - process.platform === 'win32' ? value.replace(/\\/g, '/') : value; - -describe('SessionManager GitHub session git credentials', () => { - let tempDataDir: string; - - beforeEach(() => { - tempDataDir = mkdtempSync(path.join(os.tmpdir(), 'lody-session-git-cred-')); - vi.stubEnv('LODY_DATA_DIR', tempDataDir); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - rmSync(tempDataDir, { recursive: true, force: true }); - }); - - const prepareGitHubSessionEnv = async (): Promise> => { - const manager = new SessionManager( - createLogger(), - 'token', - 'machine-1' as MachineId, - 'workspace-1' as WorkspaceId, - createWorkspaceDocument(new Map()), - { - sessionSandboxFactory: async () => createNoopSessionSandbox(), - cloudPort: createTestCloudPort(), - } - ); - const internals = manager as unknown as { - githubTokenManager: unknown; - gitCredentialBroker: unknown; - prepareGitHubRepoSessionConfig(config: SessionConfig): Promise; - }; - // The local cloud port exposes no GitHub tokens, so stand in for the hosted - // token manager and an already-started broker. - internals.githubTokenManager = { - retainRepoOwner: vi.fn(), - getAppTokenForRepo: vi.fn(async () => 'ghs_app'), - getWriteTokenInfoForRepo: vi.fn(async () => ({ token: 'ghs_write', tokenSource: 'app' })), - startAutoRefresh: vi.fn(), - }; - internals.gitCredentialBroker = { - ensureStarted: vi.fn(async () => ({ - url: 'http://127.0.0.1:33215', - port: 33215, - token: 'broker-token', - })), - activateSessionContext: vi.fn(() => 'context-token'), - getStateFilePath: vi.fn(() => path.join(tempDataDir, 'broker-workspace-1.json')), - }; - - const config = createSessionConfig({ - sessionId: 'github-session' as SessionId, - githubRepo: 'owner/repo', - env: {}, - }); - await internals.prepareGitHubRepoSessionConfig(config); - return (config.env ?? {}) as Record; - }; - - // ACP git children get the helper through GIT_CONFIG_VALUE_1. A `!node` helper - // cannot start when the desktop was launched from the Dock without node on PATH. - it('installs the helper under the CLI runtime, not a PATH `node`', async () => { - const env = await prepareGitHubSessionEnv(); - - const helperPath = path.join( - tempDataDir, - 'repos', - 'github---owner---repo', - 'lody-git-credential-helper.cjs' - ); - expect(env.GIT_CONFIG_KEY_1).toBe('credential.helper'); - expect(env.GIT_CONFIG_VALUE_1).toBe( - `!"${toShellPath(process.execPath)}" "${toShellPath(helperPath)}"` - ); - expect(existsSync(helperPath)).toBe(true); - }); - - it('forces ELECTRON_RUN_AS_NODE for ACP git children under Electron', async () => { - vi.stubEnv('ELECTRON_RUN_AS_NODE', '1'); - - expect((await prepareGitHubSessionEnv()).ELECTRON_RUN_AS_NODE).toBe('1'); - }); -}); - describe('SessionManager child session workdir resolution', () => { let tempHome: string; diff --git a/apps/cli/src/session/worktree/worktree-manager-broker-auth.test.ts b/apps/cli/src/session/worktree/worktree-manager-broker-auth.test.ts index 9b4af2379..c5987b8cb 100644 --- a/apps/cli/src/session/worktree/worktree-manager-broker-auth.test.ts +++ b/apps/cli/src/session/worktree/worktree-manager-broker-auth.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events'; -import { Readable, Writable } from 'node:stream'; +import { Readable } from 'node:stream'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -19,24 +19,15 @@ vi.mock('@/utils/file-lock', () => ({ * `stdout` is scripted per invocation so callers that parse output (fetchspec * probing, rev-parse) take their normal branches without a real repository. */ -function makeChild(stdout: string, options?: { stderr?: string; exitCode?: number }) { +function makeChild(stdout: string) { const child = new EventEmitter() as EventEmitter & { stdout: Readable; stderr: Readable; - stdin: Writable; }; child.stdout = Readable.from([stdout]); - child.stderr = Readable.from([options?.stderr ?? '']); - // The credential-helper probe writes its request to stdin before waiting. - child.stdin = new Writable({ write: (_chunk, _encoding, done) => done() }); - // Emit close only once both streams have been fully delivered, or callers that - // classify a failure from stderr would see an empty message. - let pending = 2; - const settle = () => { - if (--pending === 0) child.emit('close', options?.exitCode ?? 0); - }; - child.stdout.once('end', settle); - child.stderr.once('end', settle); + child.stderr = Readable.from([]); + // Emit close after the streams have been consumed by the caller's listeners. + queueMicrotask(() => queueMicrotask(() => child.emit('close', 0))); return child; } @@ -57,8 +48,8 @@ function createLogger(): Logger { const REPO_ID = 'github---owner---repo' as RepoId; const REPO_URL = 'https://github.com/owner/repo.git'; -/** The spawn call whose argv contains `verb`. */ -function gitCall(verb: string): [string, string[], { env: NodeJS.ProcessEnv }] { +/** Env of the git invocation whose argv contains `verb`. */ +function envOfGitCall(verb: string): NodeJS.ProcessEnv { const call = spawnMock.mock.calls.find(([, args]) => (args as string[]).includes(verb)); if (!call) { throw new Error( @@ -67,12 +58,7 @@ function gitCall(verb: string): [string, string[], { env: NodeJS.ProcessEnv }] { .join(' | ')}` ); } - return call as [string, string[], { env: NodeJS.ProcessEnv }]; -} - -/** Env of the git invocation whose argv contains `verb`. */ -function envOfGitCall(verb: string): NodeJS.ProcessEnv { - return gitCall(verb)[2].env; + return (call[2] as { env: NodeJS.ProcessEnv }).env; } describe('WorktreeManager host git credential broker routing', () => { @@ -149,113 +135,3 @@ describe('WorktreeManager host git credential broker routing', () => { expect(env.LODY_GIT_CRED_BROKER_TOKEN).toBe('ambient-token'); }); }); - -describe('WorktreeManager host git credential helper runtime', () => { - let dataDir: string; - let previousDataDir: string | undefined; - let previousRunAsNode: string | undefined; - - beforeEach(() => { - spawnMock.mockReset(); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('--get-all')) { - return makeChild('+refs/heads/*:refs/remotes/origin/*\n'); - } - if (args.includes('rev-parse')) return makeChild('deadbeef\n'); - return makeChild(''); - }); - - previousDataDir = process.env.LODY_DATA_DIR; - previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE; - delete process.env.ELECTRON_RUN_AS_NODE; - dataDir = mkdtempSync(path.join(os.tmpdir(), 'lody-helper-runtime-')); - process.env.LODY_DATA_DIR = dataDir; - mkdirSync(path.join(dataDir, 'repos', REPO_ID, 'bare.git'), { recursive: true }); - }); - - afterEach(() => { - if (previousDataDir === undefined) delete process.env.LODY_DATA_DIR; - else process.env.LODY_DATA_DIR = previousDataDir; - if (previousRunAsNode === undefined) delete process.env.ELECTRON_RUN_AS_NODE; - else process.env.ELECTRON_RUN_AS_NODE = previousRunAsNode; - rmSync(dataDir, { recursive: true, force: true }); - }); - - async function newManager() { - const { WorktreeManager } = await import('./worktree-manager'); - return new WorktreeManager({ - repoId: REPO_ID, - source: { kind: 'github', repoUrl: REPO_URL }, - logger: createLogger(), - }); - } - - // A desktop launched from the Dock has no `node` on its GUI PATH, so a `!node` - // helper never starts and git aborts with "terminal prompts disabled". - it('points credential.helper at the CLI runtime instead of a PATH lookup', async () => { - const manager = await newManager(); - await manager.ensureRepo(); - - const helperArg = gitCall('fetch')[1].find( - (arg) => arg.startsWith('credential.helper=') && arg.length > 'credential.helper='.length - ); - const helperPath = path.join( - dataDir, - 'repos', - REPO_ID, - 'lody-git-credential-helper.cjs' - ); - const toShellPath = (value: string) => - process.platform === 'win32' ? value.replace(/\\/g, '/') : value; - expect(helperArg).toBe( - `credential.helper=!"${toShellPath(process.execPath)}" "${toShellPath(helperPath)}"` - ); - expect(helperArg).not.toContain('!node '); - }); - - // The packaged desktop CLI *is* the Electron binary: without the flag, git - // running `process.execPath` would launch a second GUI app. - it('forces ELECTRON_RUN_AS_NODE on git children when running under Electron', async () => { - process.env.ELECTRON_RUN_AS_NODE = '1'; - - const manager = await newManager(); - await manager.ensureRepo(); - - expect(envOfGitCall('fetch').ELECTRON_RUN_AS_NODE).toBe('1'); - }); - - it('leaves ELECTRON_RUN_AS_NODE unset under a plain Node CLI', async () => { - const manager = await newManager(); - await manager.ensureRepo(); - - expect(envOfGitCall('fetch').ELECTRON_RUN_AS_NODE).toBeUndefined(); - }); - - it('probes the failing helper with the same runtime git uses', async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('--get-all')) { - return makeChild('+refs/heads/*:refs/remotes/origin/*\n'); - } - if (args.includes('fetch')) { - return makeChild('', { - stderr: - "fatal: could not read Username for 'https://github.com': terminal prompts disabled\n", - exitCode: 128, - }); - } - if (args.includes('rev-parse')) return makeChild('deadbeef\n'); - return makeChild(''); - }); - - const manager = await newManager(); - await manager.ensureRepo(); - - const probeCall = spawnMock.mock.calls.find(([, args]) => (args as string[]).includes('get')); - expect(probeCall).toBeDefined(); - expect(probeCall?.[0]).toBe(process.execPath); - expect(probeCall?.[1]).toEqual([ - path.join(dataDir, 'repos', REPO_ID, 'lody-git-credential-helper.cjs'), - 'get', - ]); - }); -});