mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +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:
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Case management routes.
|
||||
* Handles CRUD for cases (directories under ~/codeman-cases and linked folders),
|
||||
* fix-plan reading, and ralph-wizard file serving.
|
||||
* cloning a repository into a new case (`/api/cases/clone` + `/clone-preflight`,
|
||||
* issue #236 — the URL-safety rules live in `src/git-clone.ts`), fix-plan reading,
|
||||
* and ralph-wizard file serving.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
@@ -15,6 +17,8 @@ import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocke
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
CloneCaseSchema,
|
||||
ClonePreflightSchema,
|
||||
LinkCaseSchema,
|
||||
CaseOrderSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
@@ -26,6 +30,14 @@ import {
|
||||
DockerQuickCreateSchema,
|
||||
} from '../schemas.js';
|
||||
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
|
||||
import {
|
||||
cloneRepository,
|
||||
isGitAvailable,
|
||||
isSafeGitRef,
|
||||
parseGitRepositoryUrl,
|
||||
probeGitRemote,
|
||||
} from '../../git-clone.js';
|
||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import {
|
||||
@@ -89,6 +101,41 @@ const APP_VERSION = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Refusal text for a `local`-transport clone by a non-admin in multi-user mode.
|
||||
* Per-user case spaces live inside one $HOME, so cloning from an absolute path
|
||||
* would copy another user's workspace into the caller's own (the same escape
|
||||
* `/api/cases/link` is admin-only for).
|
||||
*/
|
||||
const LOCAL_CLONE_ADMIN_ONLY =
|
||||
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
|
||||
|
||||
/**
|
||||
* The one line of git's stderr worth appending to an error message.
|
||||
*
|
||||
* NOT the first line: `git clone` opens with "Cloning into '<dest>'…", so a naive
|
||||
* first-line pick reported the destination path as the reason a bad branch failed
|
||||
* (observed against a real remote). Prefer the LAST diagnostic line
|
||||
* (`fatal:`/`error:`/`remote:`), which is where git puts the actual cause.
|
||||
*/
|
||||
function gitDiagnosticLine(stderr: string): string {
|
||||
const lines = stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const line = [...lines].reverse().find((l) => /^(fatal|error|remote|warning):/i.test(l)) ?? lines.at(-1) ?? '';
|
||||
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the freshly cloned tree carry its own Claude settings? Those can contain
|
||||
* hooks, which run on the user's machine when a session starts in the case, so
|
||||
* the clone response says so out loud instead of silently merging into them.
|
||||
*/
|
||||
function repoShipsClaudeSettings(casePath: string): boolean {
|
||||
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
|
||||
}
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
@@ -301,6 +348,178 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Clone a repository as a case (issue #236) ==========
|
||||
|
||||
/**
|
||||
* Ask a remote what it has, without cloning anything.
|
||||
*
|
||||
* Two jobs: tell the user whether the URL they typed can be cloned *anonymously*
|
||||
* (Codeman supplies no credentials, so "private" and "typo" both have to be
|
||||
* distinguishable from "fine"), and hand back the branch/tag lists so the ref
|
||||
* field is a picker instead of a guess.
|
||||
*
|
||||
* Always 200 with `reachable: false` on a dead remote — an unreachable URL is a
|
||||
* normal answer to a preflight, not a server error, and the UI renders the reason.
|
||||
*/
|
||||
app.post(
|
||||
'/api/cases/clone-preflight',
|
||||
async (
|
||||
req,
|
||||
reply
|
||||
): Promise<ApiResponse<{ parse: GitUrlParse; remote?: GitRemoteProbe; gitAvailable: boolean }>> => {
|
||||
const { repository } = parseBody(ClonePreflightSchema, req.body);
|
||||
const parsed = parseGitRepositoryUrl(repository);
|
||||
if (!parsed.cloneable) {
|
||||
return { success: true, data: { parse: parsed, gitAvailable: isGitAvailable() } };
|
||||
}
|
||||
if (parsed.transport === 'local' && isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, LOCAL_CLONE_ADMIN_ONLY);
|
||||
}
|
||||
if (!isGitAvailable()) {
|
||||
return { success: true, data: { parse: parsed, gitAvailable: false } };
|
||||
}
|
||||
const remote = await probeGitRemote(parsed.repository);
|
||||
return { success: true, data: { parse: parsed, remote, gitAvailable: true } };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Clone a repository into the caller's case space and register it as a normal
|
||||
* local case (issue #236).
|
||||
*
|
||||
* SYNCHRONOUS by design for v1: the request stays open for the whole clone
|
||||
* (bounded by `GIT_CLONE_TIMEOUT_MS`), so there is no job store, no polling and
|
||||
* no cancellation surface to get wrong. The `case:created` broadcast is what
|
||||
* makes that safe behind a proxy with its own idle timeout — a client whose
|
||||
* request died mid-clone still sees the case appear over SSE when git finishes.
|
||||
*
|
||||
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
|
||||
* this writes only inside the caller's own `resolveCasesDir`. The one exception
|
||||
* is a `local`-transport source, which would read through that boundary.
|
||||
*
|
||||
* Repository contents win over scaffolding: an existing CLAUDE.md is left
|
||||
* alone, and hooks are MERGED into whatever `.claude/settings.local.json` the
|
||||
* repo ships (`writeHooksConfig` preserves non-Codeman handlers). A repo that
|
||||
* ships its own hooks is reported back as a warning, because those run on the
|
||||
* user's machine the moment a session starts in the case.
|
||||
*/
|
||||
app.post(
|
||||
'/api/cases/clone',
|
||||
async (
|
||||
req,
|
||||
reply
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
case: { name: string; path: string };
|
||||
repository: string;
|
||||
ref?: string;
|
||||
provider: string;
|
||||
warnings: string[];
|
||||
}>
|
||||
> => {
|
||||
const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body);
|
||||
const user = getAuthUser(req);
|
||||
|
||||
const parsed = parseGitRepositoryUrl(repository);
|
||||
if (!parsed.cloneable) return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.message);
|
||||
if (ref && !isSafeGitRef(ref)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid branch or tag name');
|
||||
}
|
||||
if (parsed.transport === 'local' && isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, LOCAL_CLONE_ADMIN_ONLY);
|
||||
}
|
||||
if (!isGitAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'git is not installed on this machine (or not on the server’s PATH).'
|
||||
);
|
||||
}
|
||||
|
||||
const casesDir = resolveCasesDir(user);
|
||||
const casePath = validatePathWithinBase(name, casesDir);
|
||||
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
|
||||
// Reject a duplicate name across EVERY case kind before invoking git, so a
|
||||
// clone can never be the thing that discovers the collision (it would have
|
||||
// spent minutes of network first, and git's own error is about a directory).
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
existsSync(casePath) ||
|
||||
linkedCases[name] ||
|
||||
dockerCases.some((item) => item.name === name) ||
|
||||
remoteCases.some((item) => item.name === name)
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// git creates the leaf, not necessarily the case space above it.
|
||||
try {
|
||||
mkdirSync(casesDir, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
|
||||
const clone = await cloneRepository({
|
||||
repository: parsed.repository,
|
||||
destination: casePath,
|
||||
...(ref ? { ref } : {}),
|
||||
...(shallow ? { shallow: true } : {}),
|
||||
});
|
||||
if (!clone.ok) {
|
||||
const code =
|
||||
clone.failure.code === 'NOT_FOUND'
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: clone.failure.code === 'DESTINATION_EXISTS'
|
||||
? ApiErrorCode.ALREADY_EXISTS
|
||||
: clone.failure.code === 'REF_NOT_FOUND'
|
||||
? ApiErrorCode.INVALID_INPUT
|
||||
: ApiErrorCode.OPERATION_FAILED;
|
||||
const detail = clone.failure.stderr
|
||||
? `${clone.failure.message} (${gitDiagnosticLine(clone.failure.stderr)})`
|
||||
: clone.failure.message;
|
||||
return createErrorResponse(code, detail);
|
||||
}
|
||||
|
||||
// Scaffold WITHOUT overwriting anything the repository shipped.
|
||||
const warnings = [...parsed.warnings];
|
||||
try {
|
||||
if (!existsSync(join(casePath, 'CLAUDE.md'))) {
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
const summary = description || `Cloned from ${parsed.repository}`;
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, summary, templatePath));
|
||||
} else {
|
||||
warnings.push('Kept the repository’s own CLAUDE.md.');
|
||||
}
|
||||
if (repoShipsClaudeSettings(casePath)) {
|
||||
warnings.push(
|
||||
'This repository ships its own .claude/settings files. Codeman merged its hooks alongside them without removing anything — review them before starting a session, since repo-supplied hooks run on this machine.'
|
||||
);
|
||||
}
|
||||
await writeHooksConfig(casePath);
|
||||
} catch (err) {
|
||||
// The clone itself succeeded: keep the case and report the scaffolding
|
||||
// problem, rather than deleting a tree the user just waited for.
|
||||
warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: { name, path: casePath },
|
||||
repository: parsed.repository,
|
||||
...(ref ? { ref } : {}),
|
||||
provider: parsed.provider,
|
||||
warnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Hosts are machine-level infra config (ssh users/identity paths): non-admins get an
|
||||
// empty list in multi-user mode, matching the admin-only write side. No-op otherwise.
|
||||
app.get('/api/remote-hosts', async (req) =>
|
||||
|
||||
Reference in New Issue
Block a user