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
79 changes: 79 additions & 0 deletions src/utils/__tests__/upstream-install-corrupt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';

import { installStatusLine } from '../claude-settings';

// Upstream 475e401 (#627): a settings file that cannot be parsed (trailing
// comma, EACCES, a read racing Claude Code's own write) used to be replaced
// with a file holding only statusLine — permissions, hooks and env lost.
// The install must abort instead.

describe('upstream 475e401: unreadable Claude settings are not overwritten', () => {
let savedEnv: Record<string, string | undefined> = {};
let workDir = '';

beforeEach(() => {
savedEnv = {
HOME: process.env.HOME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM
};
workDir = mkdtempSync(join(tmpdir(), 'ccsl-install-corrupt-'));
process.env.HOME = workDir;
process.env.CLAUDE_CONFIG_DIR = workDir;
process.env.GIT_CONFIG_GLOBAL = '/dev/null';
process.env.GIT_CONFIG_SYSTEM = '/dev/null';
});

afterEach(() => {
process.env.HOME = savedEnv.HOME;
process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
process.env.GIT_CONFIG_GLOBAL = savedEnv.GIT_CONFIG_GLOBAL;
process.env.GIT_CONFIG_SYSTEM = savedEnv.GIT_CONFIG_SYSTEM;
rmSync(workDir, { recursive: true, force: true });
});

it('aborts without touching settings that cannot be parsed', async () => {
const settingsPath = join(workDir, 'settings.json');
writeFileSync(settingsPath, '{ invalid json');

await expect(installStatusLine({ commandMode: 'auto-npx' })).rejects.toThrow(/left unchanged/);

expect(readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
});

it('keeps an unreadable file when the install is asked to keep existing', async () => {
const settingsPath = join(workDir, 'settings.json');
writeFileSync(settingsPath, '{ invalid json');

const result = await installStatusLine({ commandMode: 'auto-npx', keepExisting: true });

expect(result.statusLineWritten).toBe(false);
expect(readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
});

it('keeps the existing status line when asked to, even without keep-unreadable errors', async () => {
const settingsPath = join(workDir, 'settings.json');
const original = JSON.stringify({ statusLine: { type: 'command', command: 'old' }, env: { X: '1' } });
writeFileSync(settingsPath, original);

const result = await installStatusLine({ commandMode: 'auto-npx', keepExisting: true });

expect(result.statusLineWritten).toBe(false);
expect(readFileSync(settingsPath, 'utf-8')).toBe(original);
});
});
63 changes: 63 additions & 0 deletions src/utils/__tests__/upstream-no-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';

import {
cleanupProbe,
createProbe,
runUsageProbe,
type ProbeHandle
} from './usage-probe';

// Upstream e8ef3cb: the usage request carries the account's OAuth token, and
// a user who keeps api.anthropic.com off a proxy (NO_PROXY) still had it sent
// through HTTPS_PROXY — for example through a TLS-inspecting corporate one.

describe('upstream e8ef3cb: NO_PROXY keeps the usage request off the proxy', () => {
let probe: ProbeHandle;

beforeEach(() => {
probe = createProbe();
});

afterEach(() => {
cleanupProbe(probe);
});

it('does not configure a proxy agent for a NO_PROXY-excluded host', () => {
const result = runUsageProbe(probe, {
observe: true,
httpsProxy: 'http://127.0.0.1:9',
noProxy: 'api.anthropic.com'
});

expect(result.calls).toBeGreaterThan(0);
expect(result.agentPresent.every(present => !present)).toBe(true);
});

it('still configures the proxy agent when NO_PROXY does not exclude the host', () => {
const result = runUsageProbe(probe, {
observe: true,
httpsProxy: 'http://127.0.0.1:9',
noProxy: 'example.com'
});

expect(result.calls).toBeGreaterThan(0);
expect(result.agentPresent.some(present => present)).toBe(true);
});

it('honors the lowercase no_proxy spelling', () => {
const result = runUsageProbe(probe, {
observe: true,
httpsProxy: 'http://127.0.0.1:9',
lowercaseNoProxy: 'anthropic.com'
});

expect(result.calls).toBeGreaterThan(0);
expect(result.agentPresent.every(present => !present)).toBe(true);
});
});
79 changes: 79 additions & 0 deletions src/utils/__tests__/upstream-powerline-font-temp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
execFileSync,
execSync
} from 'node:child_process';
import {
mkdirSync,
rmSync,
statSync
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename } from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import { installPowerlineFonts } from '../powerline';

// Upstream 6aefa6b: the font installer cloned into
// os.tmpdir()/ccstatusline-powerline-fonts-<timestamp> — a name another
// account on a shared /tmp could create first (world-writable) and then
// swap install.sh before it runs. The folder now comes from mkdtemp:
// random name, created 0700 for this account.

vi.mock('node:child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn()
}));

const mockedExecSync = execSync as unknown as { mockImplementation: (impl: (command: string) => string) => void };
const mockedExecFileSync = execFileSync as unknown as { mockImplementation: (impl: (file: string, args: string[]) => string) => void };

describe('upstream 6aefa6b: powerline fonts download into a private temp folder', () => {
// Observation and cleanup track only the clone target the mock extracts
// from the installer's own execFileSync call: a shared tmpdir can hold
// leftovers from killed runs (or a real font install) that would poison a
// prefix scan and delete other accounts' directories.
const observed: { name: string; mode: number }[] = [];
const cloneTargets: string[] = [];

beforeEach(() => {
observed.length = 0;
cloneTargets.length = 0;
mockedExecSync.mockImplementation(() => '');
// git clone creates the target folder; the mock does the same so the
// folder's name and permissions can be observed mid-install
mockedExecFileSync.mockImplementation((file: string, args: string[]) => {
if (file === 'git' && args[0] === 'clone') {
const target = args.find(
argument => argument.startsWith(tmpdir()) && argument.includes('ccstatusline-powerline-fonts-')
);
if (target) {
mkdirSync(target, { recursive: true });
cloneTargets.push(target);
observed.push({ name: basename(target), mode: statSync(target).mode & 0o777 });
}
}
return '';
});
});

afterEach(() => {
for (const target of cloneTargets) {
rmSync(target, { recursive: true, force: true });
}
});

it('downloads into a randomly named folder readable only by this account', async () => {
await installPowerlineFonts();

expect(observed).toHaveLength(1);
expect(observed[0]?.name).not.toMatch(/^ccstatusline-powerline-fonts-\d+$/);
expect(observed[0]?.mode).toBe(0o700);
});
});
102 changes: 102 additions & 0 deletions src/utils/__tests__/upstream-settings-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
chmodSync,
existsSync,
mkdtempSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';

import { DEFAULT_SETTINGS } from '../../types/Settings';
import { installStatusLine } from '../claude-settings';
import {
initConfigPath,
saveSettings
} from '../config';

// Upstream 4c62e31: saving rewrote a private settings file through a temp
// file (which took the default permissions) and wrote .orig/.bak backups
// world-readable — both leak the same secrets (env, apiKeyHelper) the
// private original held.

describe('upstream 4c62e31: private settings stay private on save', () => {
let savedEnv: Record<string, string | undefined> = {};
let workDir = '';

beforeEach(() => {
savedEnv = {
HOME: process.env.HOME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM
};
workDir = mkdtempSync(join(tmpdir(), 'ccsl-settings-perms-'));
process.env.HOME = workDir;
process.env.CLAUDE_CONFIG_DIR = workDir;
process.env.GIT_CONFIG_GLOBAL = '/dev/null';
process.env.GIT_CONFIG_SYSTEM = '/dev/null';
});

afterEach(() => {
process.env.HOME = savedEnv.HOME;
process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
process.env.GIT_CONFIG_GLOBAL = savedEnv.GIT_CONFIG_GLOBAL;
process.env.GIT_CONFIG_SYSTEM = savedEnv.GIT_CONFIG_SYSTEM;
initConfigPath();
rmSync(workDir, { recursive: true, force: true });
});

it('keeps 0600 on ccstatusline settings across a save', async () => {
const configPath = join(workDir, 'settings.json');
writeFileSync(configPath, '{}');
chmodSync(configPath, 0o600);
initConfigPath(configPath);

await saveSettings(DEFAULT_SETTINGS);

expect(statSync(configPath).mode & 0o777).toBe(0o600);
});

it('gives the .orig backup of private Claude settings their 0600', async () => {
const settingsPath = join(workDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({ env: { API_KEY: 'secret' } }));
chmodSync(settingsPath, 0o600);

await installStatusLine({ commandMode: 'auto-npx' });

const origPath = `${settingsPath}.orig`;
expect(existsSync(origPath)).toBe(true);
expect(statSync(origPath).mode & 0o777).toBe(0o600);
expect(existsSync(`${settingsPath}.bak`)).toBe(true);
});

it('recreates an existing backup so it does not keep loose permissions', async () => {
const settingsPath = join(workDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({ env: { API_KEY: 'secret' } }));
chmodSync(settingsPath, 0o600);
writeFileSync(`${settingsPath}.orig`, '{ "stale": true }');
chmodSync(`${settingsPath}.orig`, 0o777);

await installStatusLine({ commandMode: 'auto-npx' });

expect(statSync(`${settingsPath}.orig`).mode & 0o777).toBe(0o600);
});

it('re-installs over an existing status line', async () => {
const settingsPath = join(workDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({ statusLine: { type: 'command', command: 'old' } }));

const result = await installStatusLine({ commandMode: 'auto-npx' });

expect(result.statusLineWritten).toBe(true);
});
});
68 changes: 68 additions & 0 deletions src/utils/__tests__/upstream-stalled-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createServer } from 'node:net';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';

import {
cleanupProbe,
createProbe,
runUsageProbe,
type ProbeHandle
} from './usage-probe';

// Upstream 610dcc9: the usage request's `timeout` only covers an idle socket,
// and behind HTTPS_PROXY the request has no socket until the proxy answers
// CONNECT — a proxy that accepts and never answers held the render forever,
// and its agent kept the process alive afterwards. The fetch now has an
// absolute deadline that also closes the proxy agent.

const PROBE_BUDGET_MS = 20000;

describe('upstream 610dcc9: a stalled proxy cannot hold the render forever', () => {
let probe: ProbeHandle;
let stalledProxy: ReturnType<typeof createServer>;

beforeEach(() => {
probe = createProbe();
// accepts the TCP connection and never responds: the CONNECT the proxy
// agent waits for never arrives
stalledProxy = createServer(() => undefined);
stalledProxy.listen(0, '127.0.0.1');
});

afterEach(() => {
stalledProxy.close();
cleanupProbe(probe);
});

it('finishes the fetch against a proxy that never answers', async () => {
// the fixed deadline itself is 5s; allow the probe process to boot and
// the deadline to fire with margin, but far under the hang of the bug
await new Promise<void>((resolve) => {
if (stalledProxy.listening) {
resolve();
return;
}
stalledProxy.once('listening', resolve);
});

const address = stalledProxy.address();
if (address === null || typeof address === 'string') {
throw new Error('stalled proxy did not start');
}

const started = Date.now();
const result = runUsageProbe(probe, {
httpsProxy: `http://127.0.0.1:${address.port}`,
timeoutMs: PROBE_BUDGET_MS
});
const elapsed = Date.now() - started;

expect(elapsed).toBeLessThan(PROBE_BUDGET_MS);
expect(result.resultKind).toBe('api-error');
}, 25000);
});
Loading
Loading