mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
1 parent
b549a66aa3
commit
41186f5ac3
6 files changed
+93
-13
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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…</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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user