diff --git a/CLAUDE.md b/CLAUDE.md index fff3794d..c555e2bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,7 +244,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **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-and-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 synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only (`intoEmptyDir` clones straight into an empty one and never removes it; one direct clone per folder at a time, and its cleanup is skipped when git wrote nothing or the mount is unresponsive). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) +**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only (`intoEmptyDir` clones straight into an empty one and never removes it; one direct clone per folder at a time, and its cleanup is skipped unless git actually ran (`GitRun.started`: a refused folder, a full queue or a slot wait that timed out wrote nothing) and when the mount is unresponsive; a folder at or inside a Docker case workspace is refused `400`, since git would run the new repository's hooks and filters on the host). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) **Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `/.claude/worktrees/` on `worktree-`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case) diff --git a/docs/api-reference.md b/docs/api-reference.md index 70f9d3a7..5713f160 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1315,7 +1315,7 @@ The target is judged before anything is written: Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`. -`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `/` as always. With `path` the repository is cloned **into that exact folder** (no `` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing. +`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `/` as always. With `path` the repository is cloned **into that exact folder** (no `` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing. ## Git status diff --git a/src/git-clone.ts b/src/git-clone.ts index 14d206a8..a50227ee 100644 --- a/src/git-clone.ts +++ b/src/git-clone.ts @@ -707,6 +707,8 @@ interface GitRun { stderr: string; code: number | null; timedOut: boolean; + /** False when git never ran (queue full, slot wait timed out, spawn failed): nothing was written. */ + started: boolean; spawnError?: string; } @@ -726,10 +728,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number) const queuedAt = Date.now(); const slot = await acquireGitSlot(timeoutMs); if (slot === 'queue-full') { - return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' }; + return { + stdout: '', + stderr: '', + code: null, + timedOut: false, + spawnError: 'EBUSY: git operation queue is full', + started: false, + }; } if (slot === 'timed-out') { - return { stdout: '', stderr: '', code: null, timedOut: true }; + return { stdout: '', stderr: '', code: null, timedOut: true, started: false }; } const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt)); try { @@ -742,7 +751,7 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number) detached: true, }); } catch (err) { - resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) }); + resolve({ stdout: '', stderr: '', code: null, timedOut: false, started: false, spawnError: String(err) }); return; } @@ -789,8 +798,10 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number) resolve(result); }; - child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) })); - child.on('close', (code) => finish({ stdout, stderr, code, timedOut })); + child.on('error', (err) => + finish({ stdout, stderr, code: null, timedOut, started: false, spawnError: String(err) }) + ); + child.on('close', (code) => finish({ stdout, stderr, code, timedOut, started: true })); }); } finally { releaseGitSlot(); @@ -918,10 +929,11 @@ export async function cloneRepository(opts: CloneOptions): Promise const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES); if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) }; const failure = classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError); - // The folder was empty and is ours alone, so what is in it is this attempt's partial tree, but only - // if git wrote anything: not when it refused the folder or never ran (slot timeout, full queue). + // The folder was empty when this attempt took it, so what is in it is this attempt's partial tree, but + // only if git actually ran: a refused folder, a full queue or a slot wait that timed out wrote nothing, + // and anything in the folder by then (files the user copied in, another case created there) is not ours. // A mount that stopped answering is left alone too, so a libuv worker is not held on it. - if (failure.code !== 'DESTINATION_EXISTS' && !direct.spawnError) { + if (direct.started && failure.code !== 'DESTINATION_EXISTS') { if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') { await emptyDirectory(opts.destination); } diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index e676a6db..8e2be286 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -51,6 +51,8 @@ import { probeGitRemote, } from '../../git-clone.js'; import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js'; +import { isPathWithin } from '../../git-worktrees.js'; +import { defaultDockerWorkspaces } from './git-status-routes.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { prepareNewCasePath } from '../case-path.js'; import { @@ -147,6 +149,9 @@ const APP_VERSION = (() => { * would copy another user's workspace into the caller's own (the same escape * `/api/cases/link` is admin-only for). */ +const DOCKER_CLONE_REFUSAL = + 'Cloning is not available into a folder inside a Docker case workspace: a container can write there, and git would run the new repository’s hooks and filters on this machine. Clone outside the workspace and link it, or clone from inside the container.'; + const LOCAL_CLONE_ADMIN_ONLY = 'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.'; @@ -790,6 +795,27 @@ export function registerCaseRoutes( } } + // git runs the new repository's hooks and filters on THIS machine once the clone finishes, and a container + // with the workspace bind-mounted can write both while it fetches. Like git status, fetch and worktrees, + // never clone at or inside a Docker case workspace. Fails closed when the Docker case list cannot be read. + try { + const roots = await Promise.all( + (await defaultDockerWorkspaces()).map((root) => fs.realpath(root).catch(() => resolve(root))) + ); + // A custom folder is already symlink-resolved; the default one is judged through its real cases dir. + const dest = + customPath !== undefined ? casePath : join(await fs.realpath(casesDir).catch(() => casesDir), name); + if (roots.some((root) => isPathWithin(dest, root))) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_CLONE_REFUSAL); + } + } catch { + reply.code(503); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Could not read the Docker case list, so Codeman cannot tell whether that folder is a Docker case workspace. Try again.' + ); + } + const clone = await cloneRepository({ repository: parsed.repository, destination: casePath, diff --git a/test/routes/case-clone-custom-folder.test.ts b/test/routes/case-clone-custom-folder.test.ts index f5a2aa0f..392a50c5 100644 --- a/test/routes/case-clone-custom-folder.test.ts +++ b/test/routes/case-clone-custom-folder.test.ts @@ -7,7 +7,7 @@ * Like case-custom-path-routes.test.ts this suite rewrites the linked-cases registry, so it refuses to * start outside the sandboxed HOME (a raw `npx vitest` would reach the real ~/.codeman). */ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; import { existsSync, @@ -24,7 +24,12 @@ import { join } from 'node:path'; import { createRouteTestHarness } from './_route-test-utils.js'; import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; import { dataPath } from '../../src/config/instance.js'; -import { cloneRepository, isGitAvailable } from '../../src/git-clone.js'; +import { + cloneRepository, + getActiveGitOperationCount, + getQueuedGitOperationCount, + isGitAvailable, +} from '../../src/git-clone.js'; import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js'; const LINKED = () => dataPath('linked-cases.json'); @@ -172,6 +177,78 @@ describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', expect(Object.keys(linked())).toEqual(['twin']); }); + it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => { + const target = join(workDir, 'queued'); + mkdirSync(target); + // Two slow ssh clones hold both git slots (no network needed: the ssh command just sleeps). + const prior = process.env.GIT_SSH_COMMAND; + process.env.GIT_SSH_COMMAND = 'sleep 4; true'; + try { + const holders = [0, 1].map((i) => + cloneRepository({ + repository: 'ssh://localhost/x.git', + destination: join(workDir, `hold-${i}`), + timeoutMs: 10_000, + }) + ); + await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2)); + const queued = cloneRepository({ + repository: origin, + destination: target, + intoEmptyDir: true, + timeoutMs: 1_000, + }); + await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1)); + // Written during the wait, so not this attempt's: a user copying files in, or another case created there. + writeFileSync(join(target, 'mine.txt'), 'keep me'); + mkdirSync(join(target, 'src')); + const result = await queued; + expect(!result.ok && result.failure.code).toBe('TIMEOUT'); + expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me'); + expect(existsSync(join(target, 'src'))).toBe(true); + await Promise.all(holders); + } finally { + if (prior === undefined) delete process.env.GIT_SSH_COMMAND; + else process.env.GIT_SSH_COMMAND = prior; + } + }, 30_000); + + it('refuses a folder at or inside a Docker case workspace, and creates nothing', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const workspace = join(workDir, 'dockerws'); + mkdirSync(workspace); + writeFileSync( + join(dataPath('docker-cases.json')), + JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: workspace, container: 'c' }]) + ); + try { + for (const path of [workspace, join(workspace, 'repo')]) { + const res = await clone(app, { name: 'inside', repository: origin, path }); + expect(res.json().errorCode, path).toBe('INVALID_INPUT'); + expect(res.json().error, path).toMatch(/Docker case workspace/); + } + expect(readdirSync(workspace)).toEqual([]); + expect(linked()).toEqual({}); + // A sibling of the workspace is fine. + const ok = await clone(app, { name: 'beside', repository: origin, path: join(workDir, 'beside') }); + expect(ok.statusCode).toBe(200); + } finally { + safeRmHomeTree(dataPath('docker-cases.json')); + } + }); + + it('fails closed when the Docker case list cannot be read', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + writeFileSync(dataPath('docker-cases.json'), '{not json'); + try { + const res = await clone(app, { name: 'blind', repository: origin, path: join(workDir, 'blind') }); + expect(res.statusCode).toBe(503); + expect(existsSync(join(workDir, 'blind'))).toBe(false); + } finally { + safeRmHomeTree(dataPath('docker-cases.json')); + } + }); + it('refuses a blocked tree such as ~/.ssh', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); mkdirSync(join(homedir(), '.ssh'), { recursive: true });