Repository navigation
Tool shells hang on interactive git credential prompts (patch available) #10355
Replies: 6 comments 1 reply
|
Confirmed, and the fix you are proposing already exists in this repository — just at a different call site, which is the part worth putting in the report.
env: { ...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', ... }All four of your variables, plus The tool shells do not get them: Your two experiments are the right framing for the write-up, and they match the code split: So the minimal patch is not new logic: lift the four variables (and |
|
Thanks, that matches the patch exactly. Both tool executors now lift the plugin-manager environment into their own ENV_OVERRIDES:
Patch sits on the fork branch (commit 426cbd4): Tests cover the two experiments you mentioned plus the shell env: (a) an inherited fake askpass must never be invoked (marker file assertion), (b) GIT_TERMINAL_PROMPT default and explicit caller override, (c) pwsh spawn spec and a real pwsh child probe. Focused suite: 55 passed | 30 skipped; doc-sync 43/43; config catalog and i18n pairing up to date. Since the repository disables Issues and does not accept external PRs, is there a preferred way to hand this patch over (I can adapt it to a branch on your side or split it if you prefer)? |
|
Follow-up: I checked upstream master (22ddee3, 2026-10-23) and The patched branch is ready and matches your guidance exactly:
Verification on that commit: focused executor suites 55 passed / 30 skipped, doc-sync 43/43, typecheck 5/5, config catalog and i18n pairing current. The askpass regression test runs a real Since CONTRIBUTING still says external PRs are not accepted and the issues/pulls endpoints are closed, what is the preferred handover? I am happy to split it into per-package commits, drop |
|
There isn't one, and no intake process is documented.
Practically, the compare link you already posted is the right artifact: anyone with push access can fetch your branch, or copy the diff. To lower the cost further, paste the diff inline as well — applied straight from the page, it does not depend on a fork staying reachable. One thing worth calibrating on: the same file says the team reads these threads but cannot reply to every post. Silence here is not a rejection, and it also is not a queue position. |
|
Understood — no intake process, the thread is the procedure. Diff inline below, against Docs-only changes (both READMEs, their i18n pairing records, and diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts
index 373c7397..5f50bf42 100644
--- a/packages/shell/bash-local/src/index.ts
+++ b/packages/shell/bash-local/src/index.ts
@@ -18,10 +18,10 @@ import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, Subpr
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
- * Model-friendly environment overrides: disable colors, pagers, and
- * interactive terminal features that would garble tool output (the same set
- * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
- * merged first into the spawn's explicit env, so a trusted caller's own entry
+ * Model-friendly environment overrides: disable colors and pagers that garble
+ * output, and disable every interactive git credential path — terminal prompt
+ * or askpass — that would block a non-interactive tool shell. Bash-tool policy
+ * — merged first into the spawn's explicit env, so a trusted caller's own entry
* still wins; the subprocess service applies its credential scrub independently.
*/
export const ENV_OVERRIDES = {
@@ -29,6 +29,11 @@ export const ENV_OVERRIDES = {
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
+ GIT_TERMINAL_PROMPT: '0',
+ GIT_ASKPASS: '',
+ SSH_ASKPASS: '',
+ SSH_ASKPASS_REQUIRE: 'never',
+ LC_ALL: 'C',
} as const
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts
index 84ce1381..8d27b9c5 100644
--- a/packages/shell/bash-local/tests/executor.spec.ts
+++ b/packages/shell/bash-local/tests/executor.spec.ts
@@ -1,15 +1,19 @@
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
+import { spawnSync } from 'node:child_process'
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { devNull, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import { ENV_OVERRIDES, LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SubprocessRuntime from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { ShellExecSpec, ShellExecution, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
+/** `git` is only needed for the credential-path tests; other hosts skip them. */
+const hasGit = spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0
+
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
@@ -62,6 +66,50 @@ describe('LocalBashExecutor.run', () => {
expect(result.timeoutMs).toBe(5_000)
})
+ it('stamps every non-interactive git prompt guard in the tool shell', async () => {
+ expect(ENV_OVERRIDES).toMatchObject({
+ GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '',
+ SSH_ASKPASS_REQUIRE: 'never', LC_ALL: 'C',
+ })
+ const { bash } = await setup()
+ const result = await run(bash, bash.resolve({ command: 'printf %s "${GIT_TERMINAL_PROMPT:-unset}"' }))
+ expect(result.stdout.text).toBe('0')
+ })
+
+ it('lets a trusted caller override every git prompt guard', async () => {
+ const { bash } = await setup()
+ const result = await run(bash, bash.resolve({
+ command: 'printf "%s|%s|%s|%s|%s" "${GIT_TERMINAL_PROMPT:-unset}" "${GIT_ASKPASS-unset}" "${SSH_ASKPASS-unset}" "${SSH_ASKPASS_REQUIRE:-unset}" "${LC_ALL:-unset}"',
+ env: {
+ GIT_TERMINAL_PROMPT: '1', GIT_ASKPASS: 'custom-git-askpass',
+ SSH_ASKPASS: 'custom-ssh-askpass', SSH_ASKPASS_REQUIRE: 'force',
+ LC_ALL: 'en_US.UTF-8',
+ },
+ }))
+ expect(result.stdout.text).toBe('1|custom-git-askpass|custom-ssh-askpass|force|en_US.UTF-8')
+ })
+
+ it.skipIf(!hasGit)('keeps askpass out of git credential prompts in the tool shell', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-askpass-'))
+ const marker = join(dir, 'prompted')
+ const askpass = join(dir, 'askpass.sh')
+ writeFileSync(askpass, `#!/bin/sh\necho prompted >> "${marker.replaceAll('\\', '/')}"\necho secret\n`, { mode: 0o700 })
+ const { bash } = await setup()
+ vi.stubEnv('GIT_ASKPASS', askpass)
+ try {
+ const result = await run(bash, bash.resolve({
+ command: "printf 'protocol=https\\nhost=example.com\\n' | git -c credential.helper= credential fill",
+ env: { GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_SYSTEM: devNull },
+ }))
+ expect(result.exitCode).not.toBe(0)
+ expect(result.stderr.text).toContain('terminal prompts disabled')
+ expect(existsSync(marker)).toBe(false)
+ } finally {
+ vi.unstubAllEnvs()
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+
it('uses config cwd, overridable per call', async () => {
const { bash } = await setup({ cwd: '/tmp' })
const fromConfig = await run(bash, bash.resolve({ command: 'pwd' }))
diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts
index 06b7c32d..81dc2bf8 100644
--- a/packages/shell/pwsh-local/src/index.ts
+++ b/packages/shell/pwsh-local/src/index.ts
@@ -28,13 +28,20 @@ import { resolvePwshPath } from './resolve.ts'
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
/**
* Model-friendly environment overrides for PowerShell: disable colors and
- * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is
+ * pagers that would garble tool output, and disable every interactive git
+ * credential path — terminal prompt or askpass — that would block a
+ * non-interactive tool shell. `TERM=dumb` is a POSIX concept and is
* deliberately absent; `NO_COLOR` is honored by modern pwsh renderers.
*/
export const ENV_OVERRIDES = {
NO_COLOR: '1',
PAGER: 'cat',
GIT_PAGER: 'cat',
+ GIT_TERMINAL_PROMPT: '0',
+ GIT_ASKPASS: '',
+ SSH_ASKPASS: '',
+ SSH_ASKPASS_REQUIRE: 'never',
+ LC_ALL: 'C',
} as const
/**
diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts
index d9adffa6..51c4f29a 100644
--- a/packages/shell/pwsh-local/tests/executor.spec.ts
+++ b/packages/shell/pwsh-local/tests/executor.spec.ts
@@ -241,6 +241,10 @@ describe('spawn construction (pure, every platform)', () => {
expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`)
expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
+ expect(subprocess.specs[0]!.env).toMatchObject({
+ GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '',
+ SSH_ASKPASS_REQUIRE: 'never', LC_ALL: 'C',
+ })
})
it('reports both unread stderr and an asynchronous provider rejection exactly once', async () => {
@@ -443,6 +447,14 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
expect(result.timeoutMs).toBe(10_000)
})
+ it('stamps the non-interactive git environment on the child', { timeout: 15_000 }, async () => {
+ const { bash } = await setup()
+ const result = await run(bash, bash.resolve({
+ command: "[Environment]::GetEnvironmentVariable('GIT_TERMINAL_PROMPT')",
+ }))
+ expect(lf(result.stdout.text)).toBe('0\n')
+ })
+
it('uses config cwd, overridable per call', async () => {
const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))The guard set mirrors Verification on |
|
Status ping — checked again today.
Not asking for a process — just keeping the evidence current for whoever has push access. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
Bash and PowerShell tool shells are non-interactive child shells. When git cannot obtain credentials, it can block in two different ways:
/dev/ttyand waits forUsername for 'https://github.com':.GIT_ASKPASS→core.askPass→SSH_ASKPASS) can run an ambient GUI or hanging askpass helper even whenGIT_TERMINAL_PROMPT=0is set.I hit this on macOS with a repo that has a GitHub HTTPS remote and a working local
ghcredential helper: the helper was not reached for that child process, the prompt appeared in the DSH terminal, and the call hung. Both hang paths are a tool-layer defect: a non-interactive shell should fail fast.Live git experiments on this machine:
GIT_TERMINAL_PROMPT=0alone + a blockingGIT_ASKPASSscript →git credential fillstill hangs.GIT_TERMINAL_PROMPT=0+GIT_ASKPASS=''→ exits immediately withfatal: could not read Username ...: terminal prompts disabled, and does not invokecore.askPassorSSH_ASKPASSfallbacks.ghcredential helper answer normally, so stored-credential pushes keep working.Environment
0.1.0-rc.8, also reproduced againstmaster@687f282eghauthenticated;~/.gitconfigrouteshttps://github.comtogh auth git-credentialReproduction
credential.helper=override, or a child environment where the configured helper cannot answer).git push./dev/ttyor on an askpass helper; the tool call hangs until its deadline.Proposed fix
Add to
ENV_OVERRIDESin bothdsh-bash-localanddsh-pwsh-local:This matches the non-interactive git environment already used by
packages/boot/plugin-manager/src/github-connection.ts. The merge order is unchanged, so an explicit caller-provided entry still wins (pinned by a test).terminal-bashis untouched because it is an interactive persistent terminal.Patch branch (fork of this repo):
b28a2010The patch also adds an askpass marker regression test, a trusted-caller override test, a real-pwsh child probe, and updates both package READMEs (en/zh), their bilingual pairing records, and the config catalog source line references.
Testing
pnpm exec vitest run packages/shell/bash-local/tests/executor.spec.ts packages/shell/pwsh-local/tests/executor.spec.ts→
Test Files 2 passed (2),Tests 55 passed | 30 skipped (85). The bash suite runs a realgit credential fillinside the tool shell with an inherited fake askpass: it must exit 128 withterminal prompts disabledand must not create the askpass marker. pwsh real-process cases self-skip on hosts without pwsh; the pure spawn-construction assertion runs on every platform.pnpm run doc-sync→43 passed, 0 failed, 0 skipped.pnpm run verify-config-catalog→3 artifact(s) are up to date.run-gates: 5 passed, 0 failed, 0 skipped(typecheck).Scope notes
GIT_SSH_COMMAND='ssh -oBatchMode=yes'is a possible follow-up if that surface matters).All reactions