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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 `!"<process.execPath>" "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.
Original file line number Diff line number Diff line change
@@ -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`。

现在宿主机助手是 `!"<process.execPath>" "helper.cjs"`(两个词都加引号;Windows 上
`\` → `/`)。在 Electron 下还会设置 `ELECTRON_RUN_AS_NODE=1`,避免把 `Lody Helper`
当作 GUI 打开。诊断探针启动的是 `execPath` 而不是 `node`。容器内的助手仍为 `!node`,
因为镜像里有 `node`,而宿主机的 execPath 并不存在于容器中。
41 changes: 38 additions & 3 deletions apps/cli/src/lib/git-credential-helper-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = !<shell command>` 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<string, string> =>
process.versions.electron || process.env.ELECTRON_RUN_AS_NODE
? { ELECTRON_RUN_AS_NODE: '1' }
: {};
4 changes: 4 additions & 0 deletions apps/cli/src/session/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1591,6 +1592,9 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {

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
Expand Down
8 changes: 8 additions & 0 deletions apps/cli/src/session/worktree/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ and file responsibilities: [../README.md](../README.md).
(per-workspace `broker-<workspaceId>.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

Expand Down
11 changes: 10 additions & 1 deletion apps/cli/src/session/worktree/worktree-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(' ')}`);
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/tests/worktree-manager.create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Expand Down
Loading