Skip to content
Merged
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
107 changes: 107 additions & 0 deletions core/__tests__/gitlab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
Expand Down Expand Up @@ -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',
},
});
});
});
90 changes: 89 additions & 1 deletion electron/__tests__/claude.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -37,6 +37,20 @@ const {
) => Promise<string>;
};

// 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);
Expand Down Expand Up @@ -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');
});
58 changes: 57 additions & 1 deletion electron/__tests__/codex.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
}
});
22 changes: 21 additions & 1 deletion electron/__tests__/login-shell-environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | undefined>>;
getLoginShellEnvironment: () => Promise<Readonly<Record<string, string>>>;
resolveLoginShellEnvironment: (
shell: string,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading