fix(cases): never let a path decide whether a refused clone empties the folder

git's refusal quotes the destination, so a folder named openssl or rustls
classified as HOST_UNREACHABLE and the cleanup emptied it. The cleanup now
tests git's own text, DESTINATION_EXISTS is matched first, and ssl/tls are
matched as words. Also: a test for the Docker refusal on the default branch,
the route JSDoc and constant comment, and the folder copy (empty, or new).
This commit is contained in:
Devvyn committed 2026-10-31 20:29:26 +08:00
1 parent b549a66aa3
commit 41186f5ac3
6 files changed
+93 -13

No files matched your search

+1 -1
View File
@@ -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 `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, 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)
+14 -5
View File
@@ -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<CloneResult>
// 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);
}
+2 -2
View File
@@ -3658,7 +3658,7 @@
</div>
<div class="form-row" id="cloneCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
<span class="form-hint">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).</span>
<span class="form-hint">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).</span>
</div>
<div class="form-row" id="cloneCustomPathRow" style="display:none">
<label>Folder</label>
@@ -3666,7 +3666,7 @@
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="cloneCasePathPreview">Pick the empty folder to clone into.</span>
<span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
+1 -1
View File
@@ -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() {
+6 -4
View File
@@ -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`).
@@ -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 });