fix(cases): skip the cleanup when git never ran, and refuse Docker workspaces

A direct clone whose git-slot wait timed out came back with no spawnError, so
the cleanup emptied a folder git never touched. GitRun now says whether git
started and the cleanup requires it. Cloning into a folder at or inside a
Docker case workspace is refused, as git status, fetch and worktrees do, since
git would run the new repository's hooks and filters on the host.
This commit is contained in:
Devvyn committed 2026-10-31 19:13:56 +08:00
1 parent 086249a386
commit b549a66aa3
5 files changed
+127 -12

No files matched your search

+20 -8
View File
@@ -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<CloneResult>
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);
}
+26
View File
@@ -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,