From bddf145da5e96de6e55153cb8d1f385adc807cc7 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 19:19:44 +0200 Subject: [PATCH 1/5] test(git-state): cover glab authentication through the login shell environment --- core/__tests__/gitlab.test.ts | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/core/__tests__/gitlab.test.ts b/core/__tests__/gitlab.test.ts index 915b8ec..ffda090 100644 --- a/core/__tests__/gitlab.test.ts +++ b/core/__tests__/gitlab.test.ts @@ -118,6 +118,7 @@ process.stdin.on('end', () => { await using _environment = createTemporaryEnvironment({ CODIFF_GLAB_PATH: fakeGlabPath, CODIFF_GLAB_TEST_CALLS: callsPath, + SHELL: undefined, }); await callback(repo, async () => @@ -421,4 +422,110 @@ describe('GitLab merge requests', () => { expect(JSON.parse(call.input)).toEqual({ body: 'Reply in the existing discussion.' }); }); }); + + test('authenticates glab from the login shell environment when the app inherited none', async () => { + await using directory = await createTemporaryDirectory('codiff-glab-login-env-'); + const repo = join(directory.path, 'repo'); + const fakeGlab = join(directory.path, 'glab'); + const fakeShell = join(directory.path, 'fake-login-shell'); + + await execFileAsync('git', ['init', repo]); + await execFileAsync('git', [ + '-C', + repo, + 'remote', + 'add', + 'origin', + 'ssh://git@gitlab.example.com/group/project.git', + ]); + + // A GUI-launched Codiff keeps launchd's minimal environment: no + // GITLAB_TOKEN, even when the user's login shell exports one. This glab + // fails auth exactly the way the real one does when the token never + // reaches it. + await writeFile( + fakeShell, + `#!/bin/sh +GITLAB_TOKEN='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await writeFile( + fakeGlab, + `#!/bin/sh +if [ "$GITLAB_TOKEN" != 'from-login-shell' ]; then + echo 'To get started with GitLab CLI, please run: glab auth login.' >&2 + exit 4 +fi +printf '%s' '{}' +`, + ); + await Promise.all([chmod(fakeShell, 0o755), chmod(fakeGlab, 0o755)]); + + await using _environment = createTemporaryEnvironment({ + CODIFF_GLAB_PATH: fakeGlab, + GITLAB_TOKEN: undefined, + SHELL: fakeShell, + }); + + await submitMergeRequestReview(repo, { + comments: [], + event: 'REQUEST_CHANGES', + source: { + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }, + }); + }); + + test('prefers the process environment over the login shell for glab', async () => { + await using directory = await createTemporaryDirectory('codiff-glab-env-precedence-'); + const repo = join(directory.path, 'repo'); + const fakeGlab = join(directory.path, 'glab'); + const fakeShell = join(directory.path, 'fake-login-shell'); + + await execFileAsync('git', ['init', repo]); + await execFileAsync('git', [ + '-C', + repo, + 'remote', + 'add', + 'origin', + 'ssh://git@gitlab.example.com/group/project.git', + ]); + + await writeFile( + fakeShell, + `#!/bin/sh +GITLAB_TOKEN='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await writeFile( + fakeGlab, + `#!/bin/sh +if [ "$GITLAB_TOKEN" != 'from-process' ]; then + echo 'Expected the process GITLAB_TOKEN to win, got:' "$GITLAB_TOKEN" >&2 + exit 4 +fi +printf '%s' '{}' +`, + ); + await Promise.all([chmod(fakeShell, 0o755), chmod(fakeGlab, 0o755)]); + + await using _environment = createTemporaryEnvironment({ + CODIFF_GLAB_PATH: fakeGlab, + GITLAB_TOKEN: 'from-process', + SHELL: fakeShell, + }); + + await submitMergeRequestReview(repo, { + comments: [], + event: 'REQUEST_CHANGES', + source: { + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/23', + }, + }); + }); }); From 6dc000eb8ff1ccad815f98b3ab7547be3998ea52 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 19:23:55 +0200 Subject: [PATCH 2/5] fix(git-state): authenticate glab with the login shell environment --- electron/git-state/merge-request.cjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index ddf179d..332e150 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -5,6 +5,7 @@ const { spawn } = require('node:child_process'); const { homedir } = require('node:os'); const { join } = require('node:path'); const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); +const { getLoginShellEnvironment } = require('../login-shell-environment.cjs'); const { getFingerprint, git, @@ -100,8 +101,11 @@ const createGlabApiArgs = (mergeRequest, args, input) => [ * @param {ReadonlyArray} args * @param {unknown} [input] */ -const glabApi = (repoRoot, mergeRequest, args, input) => - new Promise((resolve, reject) => { +const glabApi = async (repoRoot, mergeRequest, args, input) => { + // GUI launches miss login shell variables like GITLAB_TOKEN, so fill the + // gaps; the process environment wins for anything it already defines. + const environment = { ...(await getLoginShellEnvironment()), ...process.env }; + return new Promise((resolve, reject) => { let command; try { command = getGlabCommand(); @@ -112,6 +116,7 @@ const glabApi = (repoRoot, mergeRequest, args, input) => const child = spawn(command, createGlabApiArgs(mergeRequest, args, input), { cwd: repoRoot, + env: environment, stdio: ['pipe', 'pipe', 'pipe'], }); const stdout = []; @@ -134,6 +139,7 @@ const glabApi = (repoRoot, mergeRequest, args, input) => }); child.stdin.end(input == null ? undefined : JSON.stringify(input)); }); +}; /** @param {string} value */ const parseGlabJsonPages = (value) => { From 5f853699c3fa1de49dda54dcae4bbf78f9848dc4 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 19:30:18 +0200 Subject: [PATCH 3/5] test(agents): cover agent CLI authentication through the login shell environment --- electron/__tests__/claude.test.ts | 90 ++++++++++++++++++- electron/__tests__/codex.test.ts | 58 +++++++++++- .../__tests__/login-shell-environment.test.ts | 22 ++++- electron/__tests__/opencode.test.ts | 75 +++++++++++++++- electron/__tests__/pi.test.ts | 60 ++++++++++++- 5 files changed, 299 insertions(+), 6 deletions(-) diff --git a/electron/__tests__/claude.test.ts b/electron/__tests__/claude.test.ts index 5086c39..cced1a8 100644 --- a/electron/__tests__/claude.test.ts +++ b/electron/__tests__/claude.test.ts @@ -1,7 +1,7 @@ import { chmod, readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; -import { expect, test, vi } from 'vite-plus/test'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; import { createTemporaryDirectory, createTemporaryEnvironment, @@ -37,6 +37,20 @@ const { ) => Promise; }; +// Spawning Claude resolves the login shell environment, so tests either +// provide their own fake shell or run without one. +beforeEach(() => { + const shell = process.env.SHELL; + delete process.env.SHELL; + return () => { + if (shell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = shell; + } + }; +}); + test('normalizes Claude Code model preferences to known models', () => { expect(normalizeClaudeModel('claude-opus-4-8')).toBe('claude-opus-4-8'); expect(normalizeClaudeModel('gpt-4o')).toBe(DEFAULT_CLAUDE_MODEL); @@ -240,3 +254,77 @@ test('surfaces a helpful message when Claude Code is not logged in', async () => }), ).rejects.toThrow(/not logged in/i); }); + +test('authenticates Claude Code from the login shell environment when the app inherited none', async () => { + await using directory = await createTemporaryDirectory('codiff-claude-login-env-'); + const fakeShell = join(directory.path, 'fake-login-shell'); + // A GUI-launched Codiff keeps launchd's minimal environment: no + // ANTHROPIC_API_KEY, even when the user's login shell exports one. + await writeFile( + fakeShell, + `#!/bin/sh +ANTHROPIC_API_KEY='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await chmod(fakeShell, 0o755); + await using _environment = createTemporaryEnvironment({ + ANTHROPIC_API_KEY: undefined, + SHELL: fakeShell, + }); + const { calls, transport } = createCommandTransport(({ close, stdin, stdout }) => { + stdin.on('finish', () => { + stdout( + JSON.stringify({ + is_error: false, + result: '{"version":1}', + structured_output: { version: 1 }, + }), + ); + close(); + }); + }); + + await expect( + runClaude('/repo', 'prompt', { type: 'object' }, 'walkthrough.json', 'Timed out.', { + commandTransport: transport, + }), + ).resolves.toBe('{"version":1}'); + + expect(calls[0].options.env?.ANTHROPIC_API_KEY).toBe('from-login-shell'); +}); + +test('prefers the process environment over the login shell for Claude Code', async () => { + await using directory = await createTemporaryDirectory('codiff-claude-env-precedence-'); + const fakeShell = join(directory.path, 'fake-login-shell'); + await writeFile( + fakeShell, + `#!/bin/sh +ANTHROPIC_API_KEY='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await chmod(fakeShell, 0o755); + await using _environment = createTemporaryEnvironment({ + ANTHROPIC_API_KEY: 'from-process', + SHELL: fakeShell, + }); + const { calls, transport } = createCommandTransport(({ close, stdin, stdout }) => { + stdin.on('finish', () => { + stdout( + JSON.stringify({ + is_error: false, + result: '{"version":1}', + structured_output: { version: 1 }, + }), + ); + close(); + }); + }); + + await expect( + runClaude('/repo', 'prompt', { type: 'object' }, 'walkthrough.json', 'Timed out.', { + commandTransport: transport, + }), + ).resolves.toBe('{"version":1}'); + + expect(calls[0].options.env?.ANTHROPIC_API_KEY).toBe('from-process'); +}); diff --git a/electron/__tests__/codex.test.ts b/electron/__tests__/codex.test.ts index 2fe30b4..6a87f37 100644 --- a/electron/__tests__/codex.test.ts +++ b/electron/__tests__/codex.test.ts @@ -1,7 +1,7 @@ import { chmod, readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; -import { expect, test } from 'vite-plus/test'; +import { beforeEach, expect, test } from 'vite-plus/test'; import { createTemporaryDirectory, createTemporaryEnvironment, @@ -71,6 +71,20 @@ const completeCodexExec = async ( commandProcess.close(); }; +// Spawning Codex resolves the login shell environment, so tests either +// provide their own fake shell or run without one. +beforeEach(() => { + const shell = process.env.SHELL; + delete process.env.SHELL; + return () => { + if (shell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = shell; + } + }; +}); + test('normalizes OpenAI model preferences to known models', () => { expect(normalizeOpenAIModel('gpt-5.6-sol')).toBe('gpt-5.6-sol'); expect(normalizeOpenAIModel('gpt-5.6-terra')).toBe('gpt-5.6-terra'); @@ -405,3 +419,45 @@ test('surfaces structured Codex CLI errors without the full prompt stream', asyn expect(message).toContain('Invalid schema for response_format.'); expect(message).not.toContain('very long prompt'); }); + +test('authenticates Codex from the login shell environment when the app inherited none', async () => { + await using directory = await createTemporaryDirectory('codiff-codex-login-env-'); + const fakeShell = join(directory.path, 'fake-login-shell'); + // A GUI-launched Codiff keeps launchd's minimal environment: no + // OPENAI_API_KEY, even when the user's login shell exports one. Both the + // app-server and exec transports must receive the login shell variables. + await writeFile( + fakeShell, + `#!/bin/sh +OPENAI_API_KEY='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await chmod(fakeShell, 0o755); + await using _environment = createTemporaryEnvironment({ + OPENAI_API_KEY: undefined, + SHELL: fakeShell, + }); + const { calls, transport } = createCommandTransport((commandProcess) => { + if (commandProcess.args[0] === 'app-server') { + queueMicrotask(() => { + commandProcess.stderr("error: unrecognized subcommand 'app-server'"); + commandProcess.close(2); + }); + return; + } + commandProcess.stdin.on('finish', () => void completeCodexExec(commandProcess)); + }); + + await expect( + runCodex('/repo', 'prompt', {}, 'walkthrough.json', 'Timed out.', { + commandTransport: transport, + onProgress: () => {}, + }), + ).resolves.toBe('{"version":1}'); + + expect(calls[0].args[0]).toBe('app-server'); + expect(calls).toHaveLength(2); + for (const call of calls) { + expect(call.options.env?.OPENAI_API_KEY).toBe('from-login-shell'); + } +}); diff --git a/electron/__tests__/login-shell-environment.test.ts b/electron/__tests__/login-shell-environment.test.ts index 1e76117..784db2c 100644 --- a/electron/__tests__/login-shell-environment.test.ts +++ b/electron/__tests__/login-shell-environment.test.ts @@ -8,8 +8,9 @@ import { } from '../../core/__tests__/helpers/resources.ts'; const require = createRequire(import.meta.url); -const { getLoginShellEnvironment, resolveLoginShellEnvironment } = +const { getCommandEnvironment, getLoginShellEnvironment, resolveLoginShellEnvironment } = require('../login-shell-environment.cjs') as { + getCommandEnvironment: () => Promise>; getLoginShellEnvironment: () => Promise>>; resolveLoginShellEnvironment: ( shell: string, @@ -119,6 +120,25 @@ CODIFF_FAKE_TOKEN='from-login-shell' exec /bin/sh -c "$4" expect((await readFile(runsPath, 'utf8')).trim().split('\n')).toHaveLength(1); }); +test('builds command environments where the process wins over the login shell', async () => { + await using directory = await createTemporaryDirectory('codiff-login-shell-'); + const shell = await createFakeLoginShell( + directory.path, + `CODIFF_FAKE_TOKEN='from-login-shell' CODIFF_FAKE_SHARED='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await using _environment = createTemporaryEnvironment({ + CODIFF_FAKE_SHARED: 'from-process', + CODIFF_FAKE_TOKEN: undefined, + SHELL: shell, + }); + + const environment = await getCommandEnvironment(); + + expect(environment.CODIFF_FAKE_TOKEN).toBe('from-login-shell'); + expect(environment.CODIFF_FAKE_SHARED).toBe('from-process'); +}); + test('salvages a clean environment dump when a background child holds stdout open', async () => { await using directory = await createTemporaryDirectory('codiff-login-shell-'); // The shell finishes the dump and exits cleanly, but leaves behind a child diff --git a/electron/__tests__/opencode.test.ts b/electron/__tests__/opencode.test.ts index 980d36a..4881922 100644 --- a/electron/__tests__/opencode.test.ts +++ b/electron/__tests__/opencode.test.ts @@ -1,7 +1,7 @@ import { chmod, readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; -import { expect, test } from 'vite-plus/test'; +import { beforeEach, expect, test } from 'vite-plus/test'; import { createTemporaryDirectory, createTemporaryEnvironment, @@ -48,6 +48,20 @@ const { ) => Promise; }; +// Spawning OpenCode resolves the login shell environment, so tests either +// provide their own fake shell or run without one. +beforeEach(() => { + const shell = process.env.SHELL; + delete process.env.SHELL; + return () => { + if (shell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = shell; + } + }; +}); + test('exposes selectable OpenCode models while keeping its configured default', () => { expect(DEFAULT_OPENCODE_MODEL).toBe('opencode-default'); expect(FALLBACK_OPENCODE_MODEL).toBe(DEFAULT_OPENCODE_MODEL); @@ -356,3 +370,62 @@ test('passes explicit models to OpenCode and falls back when they are unavailabl expect(calls[1].args).not.toContain('--model'); expect(fallbacks).toEqual([[DEFAULT_OPENCODE_MODEL, 'anthropic/claude-sonnet-4-6']]); }); + +test('authenticates OpenCode from the login shell environment when the app inherited none', async () => { + await using directory = await createTemporaryDirectory('codiff-opencode-login-env-'); + const fakeShell = join(directory.path, 'fake-login-shell'); + // A GUI-launched Codiff keeps launchd's minimal environment: no + // OPENAI_API_KEY, even when the user's login shell exports one. Both the + // event server and the CLI fallback must receive the login shell variables + // without losing the permission overrides. + await writeFile( + fakeShell, + `#!/bin/sh +OPENAI_API_KEY='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await chmod(fakeShell, 0o755); + await using _environment = createTemporaryEnvironment({ + OPENAI_API_KEY: undefined, + SHELL: fakeShell, + }); + const { calls, transport } = createCommandTransport((commandProcess) => { + if (commandProcess.args[0] === 'serve') { + queueMicrotask(() => { + commandProcess.stderr('unknown command: serve'); + commandProcess.close(1); + }); + return; + } + commandProcess.stdin.on('finish', () => { + commandProcess.stdout( + `${JSON.stringify({ + part: { id: 'answer', text: '{"version":1}' }, + type: 'text', + })}\n`, + ); + commandProcess.close(); + }); + }); + + await expect( + runOpenCode( + '/repo', + 'prompt', + { required: ['version'], type: 'object' }, + undefined, + undefined, + { + commandTransport: transport, + onProgress: () => {}, + }, + ), + ).resolves.toBe('{"version":1}'); + + expect(calls[0].args[0]).toBe('serve'); + expect(calls).toHaveLength(2); + for (const call of calls) { + expect(call.options.env?.OPENAI_API_KEY).toBe('from-login-shell'); + expect(JSON.parse(String(call.options.env?.OPENCODE_PERMISSION))).toEqual({ '*': 'deny' }); + } +}); diff --git a/electron/__tests__/pi.test.ts b/electron/__tests__/pi.test.ts index 0e9af2b..0c47702 100644 --- a/electron/__tests__/pi.test.ts +++ b/electron/__tests__/pi.test.ts @@ -1,11 +1,14 @@ import { chmod, readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; -import { expect, test } from 'vite-plus/test'; +import { beforeEach, expect, test } from 'vite-plus/test'; import { createTemporaryDirectory, createTemporaryEnvironment, } from '../../core/__tests__/helpers/resources.ts'; +import { createCommandTransport } from './helpers/command-transport.ts'; + +type CommandTransport = ReturnType['transport']; const require = createRequire(import.meta.url); const { @@ -31,10 +34,24 @@ const { schema: unknown, outputName?: string, timeoutMessage?: string, - options?: { model?: string }, + options?: { commandTransport?: CommandTransport; model?: string }, ) => Promise; }; +// Spawning Pi resolves the login shell environment, so tests either provide +// their own fake shell or run without one. +beforeEach(() => { + const shell = process.env.SHELL; + delete process.env.SHELL; + return () => { + if (shell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = shell; + } + }; +}); + test('exposes the Pi default model identifier', () => { expect(DEFAULT_PI_MODEL).toBe('pi-default'); expect(FALLBACK_PI_MODEL).toBe('pi-default'); @@ -117,3 +134,42 @@ process.stdin.on('end', () => { expect(stdin).toContain('prompt'); expect(stdin).toContain('Follow this JSON Schema exactly'); }); + +test('authenticates Pi from the login shell environment when the app inherited none', async () => { + await using directory = await createTemporaryDirectory('codiff-pi-login-env-'); + const fakeShell = join(directory.path, 'fake-login-shell'); + // A GUI-launched Codiff keeps launchd's minimal environment: no + // ANTHROPIC_API_KEY, even when the user's login shell exports one. + await writeFile( + fakeShell, + `#!/bin/sh +ANTHROPIC_API_KEY='from-login-shell' exec /bin/sh -c "$4" +`, + ); + await chmod(fakeShell, 0o755); + await using _environment = createTemporaryEnvironment({ + ANTHROPIC_API_KEY: undefined, + SHELL: fakeShell, + }); + const { calls, transport } = createCommandTransport(({ close, stdin, stdout }) => { + stdin.on('finish', () => { + stdout('{"version":1}'); + close(); + }); + }); + + await expect( + runPi( + '/repo', + 'prompt', + { required: ['version'], type: 'object' }, + 'walkthrough.json', + undefined, + { + commandTransport: transport, + }, + ), + ).resolves.toBe('{"version":1}'); + + expect(calls[0].options.env?.ANTHROPIC_API_KEY).toBe('from-login-shell'); +}); From 1f2fddb2aff94cde371dd53fc85fe42bb11b7dc1 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 19:32:55 +0200 Subject: [PATCH 4/5] fix(agents): authenticate agent CLIs with the login shell environment --- electron/claude.cjs | 9 ++++++--- electron/codex.cjs | 9 ++++++--- electron/login-shell-environment.cjs | 14 ++++++++++++++ electron/opencode.cjs | 12 ++++++++---- electron/pi.cjs | 4 +++- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/electron/claude.cjs b/electron/claude.cjs index 175904a..78ea976 100644 --- a/electron/claude.cjs +++ b/electron/claude.cjs @@ -3,6 +3,7 @@ const { homedir } = require('node:os'); const { join } = require('node:path'); const { resolveAgentCommandTransport } = require('./agent-command.cjs'); +const { getCommandEnvironment } = require('./login-shell-environment.cjs'); const { findExecutableOnPath, isExecutableFile, @@ -234,8 +235,9 @@ const runClaude = async ( const timeoutMs = options.timeoutMs ?? CLAUDE_TIMEOUT_MS; /** @param {string} claudeModel @returns {Promise} */ - const invokeClaude = async (claudeModel) => - /** @type {Promise} */ ( + const invokeClaude = async (claudeModel) => { + const environment = await getCommandEnvironment(); + return /** @type {Promise} */ ( new Promise((resolve, reject) => { let stderr = ''; /** @type {Error | null} */ @@ -267,7 +269,7 @@ const runClaude = async ( ]; const child = commandTransport.spawn(commandTransport.command, claudeArgs, { cwd: repoRoot, - env: process.env, + env: environment, stdio: ['pipe', 'pipe', 'pipe'], }); const streamParser = streamProgress ? createClaudeStreamParser(options.onProgress) : null; @@ -349,6 +351,7 @@ const runClaude = async ( child.stdin.end(prompt, () => {}); }) ); + }; try { return await invokeClaude(model); diff --git a/electron/codex.cjs b/electron/codex.cjs index 8dba939..8ae1144 100644 --- a/electron/codex.cjs +++ b/electron/codex.cjs @@ -4,6 +4,7 @@ const { promises: fs } = require('node:fs'); const { homedir, tmpdir } = require('node:os'); const { join } = require('node:path'); const { resolveAgentCommandTransport } = require('./agent-command.cjs'); +const { getCommandEnvironment } = require('./login-shell-environment.cjs'); const { cleanText, findExecutableOnPath, @@ -386,6 +387,7 @@ const runCodex = async ( const outputPath = join(directory, outputName); const schemaPath = join(directory, 'schema.json'); await fs.writeFile(schemaPath, JSON.stringify(schema), 'utf8'); + const environment = await getCommandEnvironment(); return await /** @type {Promise} */ ( new Promise((resolve, reject) => { @@ -421,7 +423,7 @@ const runCodex = async ( '-', ]; const child = commandTransport.spawn(commandTransport.command, codexArgs, { - env: process.env, + env: environment, stdio: ['pipe', 'pipe', 'pipe'], }); const eventParser = createCodexEventParser(options.onProgress); @@ -498,8 +500,9 @@ const runCodex = async ( * @param {string} codexModel * @returns {Promise} */ - const invokeCodexAppServer = (codexModel) => { + const invokeCodexAppServer = async (codexModel) => { const reasoningEffort = getOpenAIModelReasoningEffort(codexModel, options.reasoningEffort); + const environment = await getCommandEnvironment(); return new Promise((resolve, reject) => { const commandTransport = resolveAgentCommandTransport( options.commandTransport, @@ -510,7 +513,7 @@ const runCodex = async ( ['app-server', '--stdio', '-c', `model_reasoning_effort="${reasoningEffort}"`], { cwd: repoRoot, - env: process.env, + env: environment, stdio: ['pipe', 'pipe', 'pipe'], }, ); diff --git a/electron/login-shell-environment.cjs b/electron/login-shell-environment.cjs index 3b696d2..7114762 100644 --- a/electron/login-shell-environment.cjs +++ b/electron/login-shell-environment.cjs @@ -12,6 +12,19 @@ const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; /** @type {Map>>>} */ const cache = new Map(); +/** + * Environment for spawning CLIs on the user's behalf: the login shell fills + * variables a GUI launch never inherited, such as `GH_TOKEN` or + * `ANTHROPIC_API_KEY`, and the process environment wins for anything it + * already defines. + * + * @returns {Promise>} + */ +const getCommandEnvironment = async () => ({ + ...(await getLoginShellEnvironment()), + ...process.env, +}); + /** * The environment of the user's interactive login shell, resolved once per * shell and cached. GUI launches inherit launchd's minimal environment, so variables @@ -91,6 +104,7 @@ const parseEnvironment = (output) => { }; module.exports = { + getCommandEnvironment, getLoginShellEnvironment, resolveLoginShellEnvironment, }; diff --git a/electron/opencode.cjs b/electron/opencode.cjs index bc6aee2..7821682 100644 --- a/electron/opencode.cjs +++ b/electron/opencode.cjs @@ -4,6 +4,7 @@ const { createServer } = require('node:net'); const { homedir } = require('node:os'); const { join } = require('node:path'); const { resolveAgentCommandTransport } = require('./agent-command.cjs'); +const { getCommandEnvironment } = require('./login-shell-environment.cjs'); const { buildSchemaReminder, findExecutableOnPath, @@ -343,8 +344,9 @@ const runOpenCode = async ( const effectivePrompt = `${prompt}${buildSchemaReminder(schema)}`; /** @param {string} openCodeModel */ - const invokeOpenCodeCli = (openCodeModel) => - /** @type {Promise} */ ( + const invokeOpenCodeCli = async (openCodeModel) => { + const environment = await getCommandEnvironment(); + return /** @type {Promise} */ ( new Promise((resolve, reject) => { let stderr = ''; /** @type {Error | null} */ @@ -370,7 +372,7 @@ const runOpenCode = async ( const child = commandTransport.spawn(commandTransport.command, opencodeArgs, { cwd: repoRoot, env: { - ...process.env, + ...environment, OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }), }, stdio: ['pipe', 'pipe', 'pipe'], @@ -430,6 +432,7 @@ const runOpenCode = async ( child.stdin.end(effectivePrompt, () => {}); }) ); + }; /** @param {string} openCodeModel */ const invokeOpenCodeServer = async (openCodeModel) => { @@ -438,13 +441,14 @@ const runOpenCode = async ( getOpenCodeCommand, ); const port = await reserveOpenCodePort(); + const environment = await getCommandEnvironment(); const child = commandTransport.spawn( commandTransport.command, ['serve', '--pure', '--hostname=127.0.0.1', `--port=${port}`], { cwd: repoRoot, env: { - ...process.env, + ...environment, OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }), }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/electron/pi.cjs b/electron/pi.cjs index 2f4d147..d061c2a 100644 --- a/electron/pi.cjs +++ b/electron/pi.cjs @@ -3,6 +3,7 @@ const { homedir } = require('node:os'); const { join } = require('node:path'); const { resolveAgentCommandTransport } = require('./agent-command.cjs'); +const { getCommandEnvironment } = require('./login-shell-environment.cjs'); const { buildSchemaReminder, findExecutableOnPath, @@ -125,6 +126,7 @@ const runPi = async ( const model = normalizePiModel(options.model); const timeoutMs = options.timeoutMs ?? PI_TIMEOUT_MS; const effectivePrompt = `${prompt}${buildSchemaReminder(schema)}`; + const environment = await getCommandEnvironment(); return await /** @type {Promise} */ ( new Promise((resolve, reject) => { @@ -147,7 +149,7 @@ const runPi = async ( ]; const child = commandTransport.spawn(commandTransport.command, piArgs, { cwd: repoRoot, - env: process.env, + env: environment, stdio: ['pipe', 'pipe', 'pipe'], }); From c9fbb85e96cbf9edb6d5456440d8f4bb82cdb33b Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 19:33:44 +0200 Subject: [PATCH 5/5] refactor(git-state): share the command environment helper for gh and glab --- electron/git-state/merge-request.cjs | 6 ++---- electron/git-state/pull-request.cjs | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index 332e150..2e3345a 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -5,7 +5,7 @@ const { spawn } = require('node:child_process'); const { homedir } = require('node:os'); const { join } = require('node:path'); const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); -const { getLoginShellEnvironment } = require('../login-shell-environment.cjs'); +const { getCommandEnvironment } = require('../login-shell-environment.cjs'); const { getFingerprint, git, @@ -102,9 +102,7 @@ const createGlabApiArgs = (mergeRequest, args, input) => [ * @param {unknown} [input] */ const glabApi = async (repoRoot, mergeRequest, args, input) => { - // GUI launches miss login shell variables like GITLAB_TOKEN, so fill the - // gaps; the process environment wins for anything it already defines. - const environment = { ...(await getLoginShellEnvironment()), ...process.env }; + const environment = await getCommandEnvironment(); return new Promise((resolve, reject) => { let command; try { diff --git a/electron/git-state/pull-request.cjs b/electron/git-state/pull-request.cjs index ce38187..3ae6660 100644 --- a/electron/git-state/pull-request.cjs +++ b/electron/git-state/pull-request.cjs @@ -4,7 +4,7 @@ const { spawn } = require('node:child_process'); const { homedir } = require('node:os'); const { join } = require('node:path'); const { findExecutableOnPath, isExecutableFile } = require('../agent-shared.cjs'); -const { getLoginShellEnvironment } = require('../login-shell-environment.cjs'); +const { getCommandEnvironment } = require('../login-shell-environment.cjs'); const { IMAGE_FILE_LIMIT, bufferToImageRevision, @@ -278,9 +278,7 @@ const fetchPullRequestHistoryRefs = (repoRoot, remote, pullRequest, metadata) => * @returns {Promise<{code: number | null; stderr: string; stdout: Buffer}>} */ const runGhApi = async (repoRoot, args, input) => { - // GUI launches miss login shell variables like GH_TOKEN, so fill the gaps; - // the process environment wins for anything it already defines. - const environment = { ...(await getLoginShellEnvironment()), ...process.env }; + const environment = await getCommandEnvironment(); return new Promise((resolve, reject) => { const child = spawn(getGhCommand(), ['api', ...args], { cwd: repoRoot,