mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
feat(cases): clone a Git repository as a new case (#236)
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>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @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, mkdirSync, mkdtempSync, readFileSync, rmSync, 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;
|
||||
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);
|
||||
});
|
||||
|
||||
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('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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user