diff --git a/CLAUDE.md b/CLAUDE.md index c555e2bc..5d37fc8c 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 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) +**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 full queue or a slot wait that timed out wrote nothing) and did not refuse the folder (git's own `already exists and is not an empty directory` text, tested directly and never through the classifier's code, since the refusal quotes the path and a folder named `openssl` once classified as unreachable) 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/src/git-clone.ts b/src/git-clone.ts index a50227ee..51484d7a 100644 --- a/src/git-clone.ts +++ b/src/git-clone.ts @@ -596,6 +596,11 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError stderr: clean, }; } + // First: git's refusal quotes the destination path, so a folder named openssl, rustls or "not found" would + // otherwise match the host and repository patterns below, and a wrong code here decides a deletion. + 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 }; + } if (timedOut) { return { code: 'TIMEOUT', @@ -631,12 +636,13 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError stderr: clean, }; } - if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) { + if ( + /could not resolve host|connection refused|connection timed out|network is unreachable|\bssl\b|\btls\b|certificate|handshake/.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 }; } @@ -933,7 +939,10 @@ export async function cloneRepository(opts: CloneOptions): Promise // 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 (direct.started && failure.code !== 'DESTINATION_EXISTS') { + // git's own refusal is tested on its text, not on the classifier's code, so the deletion never depends on + // which pattern matched first. + const refused = /already exists and is not an empty directory/i.test(direct.stderr); + if (direct.started && !refused && failure.code !== 'DESTINATION_EXISTS') { if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') { await emptyDirectory(opts.destination); } diff --git a/src/web/public/index.html b/src/web/public/index.html index d88d2ec7..3431915f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3658,7 +3658,7 @@
- By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (it must be empty); it is listed like any other case, and deleting the case only unlinks it (your files stay). + By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); it is listed like any other case, and deleting the case only unlinks it (your files stay).
- Pick the empty folder to clone into. + Pick an empty folder, or one that does not exist yet, to clone into.
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index c1455d00..9407e3bd 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4159,7 +4159,7 @@ Object.assign(CodemanApp.prototype, { const hint = document.getElementById('cloneCasePathPreview'); if (!hint) return; const target = this._cloneTargetPath(); - hint.textContent = target ? `Will clone into: ${target}` : 'Pick the empty folder to clone into.'; + hint.textContent = target ? `Will clone into: ${target}` : 'Pick an empty folder, or one that does not exist yet, to clone into.'; }, openCloneCasePathPicker() { diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 8e2be286..ecb3a667 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -149,12 +149,13 @@ 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.'; +/** Refusal text for a clone at or inside a Docker case workspace (git would run its hooks on the host). */ +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.'; + /** * Whether a clone or preflight must run with git's credential helpers cleared: * a non-admin in multi-user mode. Every user's git runs as the one server @@ -666,7 +667,8 @@ export function registerCaseRoutes( * 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`. Two things would + * this writes only inside the caller's own `resolveCasesDir`, EXCEPT with a `path` (a custom folder, + * registered as a linked case in the shared registry), which is admin-only like Link Existing. Two things would * otherwise read through that boundary: a `local`-transport source (refused for * non-admins) and the server account's git credential helpers, which every user * shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`). diff --git a/test/routes/case-clone-custom-folder.test.ts b/test/routes/case-clone-custom-folder.test.ts index 392a50c5..2c8ee229 100644 --- a/test/routes/case-clone-custom-folder.test.ts +++ b/test/routes/case-clone-custom-folder.test.ts @@ -25,6 +25,7 @@ import { createRouteTestHarness } from './_route-test-utils.js'; import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; import { dataPath } from '../../src/config/instance.js'; import { + classifyGitFailure, cloneRepository, getActiveGitOperationCount, getQueuedGitOperationCount, @@ -249,6 +250,74 @@ describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', } }); + it.each(['plain', 'openssl', 'rustls', 'does not exist'])( + 'a refused folder named %s keeps what was written meanwhile and reports DESTINATION_EXISTS', + async (folder) => { + const target = join(workDir, folder); + mkdirSync(target); + const prior = process.env.GIT_SSH_COMMAND; + process.env.GIT_SSH_COMMAND = 'sleep 2; 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)); + // Queues behind the holders but has time to get a slot, so git runs and refuses the folder. + const queued = cloneRepository({ + repository: origin, + destination: target, + intoEmptyDir: true, + timeoutMs: 15_000, + }); + await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1)); + writeFileSync(join(target, 'mine.txt'), 'keep me'); + const result = await queued; + expect(!result.ok && result.failure.code).toBe('DESTINATION_EXISTS'); + expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me'); + await Promise.all(holders); + } finally { + if (prior === undefined) delete process.env.GIT_SSH_COMMAND; + else process.env.GIT_SSH_COMMAND = prior; + } + }, + 30_000 + ); + + it('classifies git stderr by what it says, not by words in a path it quotes', () => { + const refusal = (dir: string) => `fatal: destination path '${dir}' already exists and is not an empty directory.`; + for (const dir of ['/home/u/src/openssl', '/home/u/rustls', '/x/tls-test', '/x/repository not found']) { + expect(classifyGitFailure(refusal(dir), false).code, dir).toBe('DESTINATION_EXISTS'); + } + expect(classifyGitFailure('fatal: unable to access: SSL certificate problem: self signed', false).code).toBe( + 'HOST_UNREACHABLE' + ); + expect(classifyGitFailure('fatal: TLS handshake failure', false).code).toBe('HOST_UNREACHABLE'); + expect(classifyGitFailure('fatal: could not resolve host: example.invalid', false).code).toBe('HOST_UNREACHABLE'); + expect(classifyGitFailure("fatal: repository 'https://x/y.git/' not found", false).code).toBe('NOT_FOUND'); + }); + + it('refuses a clone without a path into the cases folder when it is a Docker case workspace', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const casesDir = join(homedir(), 'codeman-cases'); + mkdirSync(casesDir, { recursive: true }); + writeFileSync( + dataPath('docker-cases.json'), + JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: casesDir, container: 'c' }]) + ); + try { + const res = await clone(app, { name: 'default-inside', repository: origin }); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + expect(res.json().error).toMatch(/Docker case workspace/); + expect(existsSync(join(casesDir, 'default-inside'))).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 });