diff --git a/CLAUDE.md b/CLAUDE.md index 4f350fdb..574b499f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,7 +183,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui) -**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails. +**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`. **Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a269ad3..eee603c0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1358,9 +1358,9 @@ The target is judged before anything is written: - The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`. - `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case. -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`. +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`, or `409 ALREADY_EXISTS` when the name was linked meanwhile, or `409 CONFLICT` when `linked-cases.json` cannot be read or is not a JSON object. Such a file is refused and left as it is, because writing over it would unlink every other case; `POST /api/cases/link` refuses it the same way. -`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. +`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 case cannot be registered, the cloned folder is kept and the error names it, then use Link Existing: `409 ALREADY_EXISTS` when the name was linked while git ran, `409 CONFLICT` when `linked-cases.json` is refused as above, and `500 OPERATION_FAILED` when the write itself fails. ## Git status @@ -1458,7 +1458,7 @@ A local case whose folder is the top of a git repository can keep **worktrees** A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key). -`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name). +`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name). ### System operations diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 479d279b..d37c592e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -474,7 +474,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above) ### Clone a repository as a case -**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. +**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder. - ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c ` makes git run an arbitrary command as its transport, and ANY `::` dispatches to a `git-remote-` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts. - ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. diff --git a/src/web/linked-cases-writer.ts b/src/web/linked-cases-writer.ts new file mode 100644 index 00000000..ac8c8c37 --- /dev/null +++ b/src/web/linked-cases-writer.ts @@ -0,0 +1,117 @@ +/** + * @fileoverview The writer of the linked-cases registry (`linked-cases.json` in the data dir): case + * name to the absolute folder it stands for. + * + * Every read-modify-write of that file goes through `mutateLinkedCases()`, the same shape as + * `mutateSettingsFile()` (settings-writer.ts). The one direct writer is config-backup restore + * (`restoreConfigBackup()`), which must work on a file that does not parse. Readers keep reading the + * file directly; a write is atomic, so they never see half of one. + * + * - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, changing + * and writing before the next starts. Link, unlink, rename, a create in a custom folder and a clone + * into one each used to read the file, check it and write it back on their own. Two clones that + * claimed one name and finished together both passed the check, both answered success, and the + * later write replaced the earlier one's entry. Any two writers could lose an entry the same way. + * A check that must hold when the entry is written belongs inside `fn`: throwing there rejects that + * caller only, and nothing is written. + * - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, so a + * reader never sees a half-written file (quick-start would read one as an empty registry and resolve + * a linked case to the cases dir instead), and a failed write leaves the previous file in place. + * - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file that + * cannot be read or is not a JSON object is left alone and the write throws + * `LinkedCasesWriteRefusedError`: read as `{}`, the next link would write back its own entry alone + * and unlink every other case. + */ + +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { dataPath } from '../config/instance.js'; +import { getErrorMessage } from '../types.js'; + +export type LinkedCases = Record; + +/** Resolved at call time, like every other `dataPath()` caller that tests point at a temp HOME. */ +export function linkedCasesPath(): string { + return dataPath('linked-cases.json'); +} + +/** A write refused because the existing registry must not be overwritten. The message is user-facing. */ +export class LinkedCasesWriteRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'LinkedCasesWriteRefusedError'; + } +} + +async function readLinkedCasesForWrite(path: string): Promise { + let raw: string; + try { + raw = await fs.readFile(path, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw new LinkedCasesWriteRefusedError( + `Cannot read ${path} (${(err as Error).message}), so the linked cases were not changed.` + ); + } + if (raw.trim() === '') return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new LinkedCasesWriteRefusedError( + `${path} is not a valid JSON object, so the linked cases were not changed (saving would have unlinked every other case). Fix or delete the file, or restore it from a config backup, then try again.` + ); + } + return parsed as LinkedCases; +} + +async function writeLinkedCasesFile(path: string, linked: LinkedCases): Promise { + // Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file. + const target = await fs.realpath(path).catch(() => path); + await fs.mkdir(dirname(target), { recursive: true }); + // A plain writeFile kept the file's permissions; a rename would not, so the temp file takes them. + const mode = await fs.stat(target).then( + (s) => s.mode & 0o777, + () => undefined + ); + const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(linked, null, 2), mode === undefined ? undefined : { mode }); + // The creation mode is masked by the umask; the existing file's bits are put back as they were. + if (mode !== undefined) await fs.chmod(tmp, mode); + await fs.rename(tmp, target); + } catch (err) { + await fs.rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +let mutationChain: Promise = Promise.resolve(); + +/** + * Run one change to the linked-cases registry. `fn` receives a copy of the current registry and + * returns the registry to write, or `undefined` to write nothing; it may throw to refuse the change + * (its error rejects this call as thrown). Resolves with the registry before and after the change + * (`after` is `undefined` when nothing was written). A refused or failed mutation rejects its own + * caller only; the chain keeps going. + */ +export function mutateLinkedCases( + fn: (current: LinkedCases) => LinkedCases | undefined +): Promise<{ before: LinkedCases; after: LinkedCases | undefined }> { + const run = mutationChain.then(async () => { + const path = linkedCasesPath(); + const before = await readLinkedCasesForWrite(path).catch((err: unknown) => { + console.warn(`[linked-cases] write refused: ${getErrorMessage(err)}`); + throw err; + }); + const after = fn({ ...before }); + if (after) await writeLinkedCasesFile(path, after); + return { before, after }; + }); + mutationChain = run.catch(() => {}); + return run; +} diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 0cfc5f5d..db756e11 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -86,6 +86,7 @@ import { SettingsWriteRefusedError, settingsWriteErrorResponse, } from '../settings-writer.js'; +import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; @@ -126,7 +127,6 @@ import { writeRemoteHosts, } from '../../remote-hosts.js'; -const LINKED_CASES_FILE = dataPath('linked-cases.json'); const CODEMAN_CONFIG_DIR = getDataDir(); const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; const DOCKER_EXPORTS_DIR = dataPath('docker-exports'); @@ -212,11 +212,21 @@ function agentCreatedInfo(marker: AgentCaseMarker): NonNullable> { - return readJsonConfig>(LINKED_CASES_FILE, 'linked cases', {}); + return readJsonConfig>(linkedCasesPath(), 'linked cases', {}); } +/** A conflict thrown from inside a `mutateLinkedCases()` callback: answered 409, nothing written. */ +function linkedConflict(message: string): Error { + return Object.assign(new Error(message), { conflict: true }); +} +const isLinkedConflict = (err: unknown): boolean => (err as { conflict?: boolean }).conflict === true; + /** * Resolve a case name to its directory path, checking linked cases first, then the * user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise). @@ -553,15 +563,11 @@ export function registerCaseRoutes( made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too await writeHooksConfig(casePath); - const codemanDir = getDataDir(); - if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true }); - // Re-read right before writing, so a case linked since the check above is not dropped. This only - // narrows the window: like POST /api/cases/link, the registry write is not serialized, and two - // requests that both read before either writes can still lose one entry. - const fresh = await readLinkedCases(); - if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true }); - fresh[name] = casePath; - await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2)); + // The check above ran before the scaffold; a case linked since then wins and this call is undone. + await mutateLinkedCases((current) => { + if (current[name]) throw linkedConflict(`Case "${name}" was just linked`); + return { ...current, [name]: casePath }; + }); ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath }); return { success: true, data: { case: { name, path: casePath } } }; @@ -569,10 +575,14 @@ export function registerCaseRoutes( // Undo only what this call made. The whole folder if we created it, otherwise the scaffold // entries inside the empty folder the user picked; never anything else. for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined); - if ((err as { conflict?: boolean }).conflict) { + if (isLinkedConflict(err)) { reply.code(409); return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err)); } + if (err instanceof LinkedCasesWriteRefusedError) { + reply.code(409); + return createErrorResponse(ApiErrorCode.CONFLICT, err.message); + } reply.code(500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } @@ -890,20 +900,20 @@ export function registerCaseRoutes( } if (customPath !== undefined) { - // Re-read right before writing so a case linked while git ran is not dropped. Like - // POST /api/cases/link the write is not serialized, so this only narrows the window. + // The name was checked before git ran, which can take minutes. Checked again where the entry is + // written, so of two clones that claimed one name only the first to finish links it. try { - const fresh = await readLinkedCases(); - if (fresh[name]) - throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true }); - fresh[name] = casePath; - await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2)); + await mutateLinkedCases((current) => { + if (current[name]) throw linkedConflict(`Case "${name}" was linked while the clone ran`); + return { ...current, [name]: casePath }; + }); } catch (err) { // The clone itself succeeded: keep the folder (the user waited for it) and say where it is. - const conflict = (err as { conflict?: boolean }).conflict; - reply.code(conflict ? 409 : 500); + const conflict = isLinkedConflict(err); + const refused = err instanceof LinkedCasesWriteRefusedError; + reply.code(conflict || refused ? 409 : 500); return createErrorResponse( - conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED, + conflict ? ApiErrorCode.ALREADY_EXISTS : refused ? ApiErrorCode.CONFLICT : ApiErrorCode.OPERATION_FAILED, `Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.` ); } @@ -1713,28 +1723,17 @@ export function registerCaseRoutes( return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.'); } - // Load existing linked cases - const linkedCases = await readLinkedCases(); - - // Check if name is already linked - if (linkedCases[name]) { - return createErrorResponse( - ApiErrorCode.ALREADY_EXISTS, - `Case "${name}" is already linked to ${linkedCases[name]}` - ); - } - - // Save the linked case - linkedCases[name] = expandedPath; + // The name check and the write are one step, so two links of one name cannot both succeed. try { - const codemanDir = getDataDir(); - if (!existsSync(codemanDir)) { - mkdirSync(codemanDir, { recursive: true }); - } - await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2)); + await mutateLinkedCases((current) => { + if (current[name]) throw linkedConflict(`Case "${name}" is already linked to ${current[name]}`); + return { ...current, [name]: expandedPath }; + }); ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath }); return { success: true, data: { case: { name, path: expandedPath } } }; } catch (err) { + if (isLinkedConflict(err)) return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err)); + if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); @@ -1815,13 +1814,19 @@ export function registerCaseRoutes( // must not unlink one either; skip so control falls through to their local delete. const linkedCases = await readLinkedCases(); if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) { - delete linkedCases[name]; try { - await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2)); + // Removes this one entry from the registry as it is now, keeping any linked since the read above. + await mutateLinkedCases((current) => { + if (!Object.hasOwn(current, name)) return undefined; + const next = { ...current }; + delete next[name]; + return next; + }); ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' }); await forgetCaseDefault(); return { success: true, data: { name } }; } catch (err) { + if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } } @@ -2007,7 +2012,6 @@ export function registerCaseRoutes( stopped++; } if (moveFolder) await moveCaseFolder(oldPath, newPath); - const originalLinks = isLinked ? await readLinkedCases() : undefined; // The settings.json values this rename replaced, set only once it wrote them. A failure puts back // these keys alone, so a settings save that landed in between is kept. let settingsUndo: Record | undefined; @@ -2027,16 +2031,19 @@ export function registerCaseRoutes( settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]])); } } - if (originalLinks) { - const links = { ...originalLinks }; - delete links[name]; - links[newName] = newPath; - try { - await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2)); - } catch (err) { - await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {}); - throw err; - } + if (isLinked) { + // Checked again where the entry is written: the checks above ran before the sessions were + // stopped and the folder moved. A failed write leaves the registry as it was. + await mutateLinkedCases((current) => { + if (current[newName]) throw new CaseRenameError('EXISTS', `A case named "${newName}" already exists`); + if (current[name] !== oldPath) { + throw new CaseRenameError('BUSY', `Case "${name}" was changed while it was being renamed`); + } + const links = { ...current }; + delete links[name]; + links[newName] = newPath; + return links; + }); } } catch (err) { // The folder moved but the registries did not follow: put settings.json and the folder back @@ -2081,6 +2088,9 @@ export function registerCaseRoutes( : ApiErrorCode.OPERATION_FAILED; return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped }); } + if (err instanceof LinkedCasesWriteRefusedError) { + return fail(409, ApiErrorCode.CONFLICT, err.message, { stoppedSessions: stopped }); + } return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped }); } }); diff --git a/test/linked-cases-writer.test.ts b/test/linked-cases-writer.test.ts new file mode 100644 index 00000000..f9ecd1c9 --- /dev/null +++ b/test/linked-cases-writer.test.ts @@ -0,0 +1,198 @@ +/** + * @fileoverview Every writer of the linked-cases registry is serialized, atomic, and refuses to + * overwrite a file that does not parse (`mutateLinkedCases()`, src/web/linked-cases-writer.ts). Real + * filesystem under test/setup.ts's temp HOME. Port: N/A (app.inject()). + * + * The CI flake this fixes was "a name linked while the clone ran is a 409" in + * test/routes/case-clone-custom-folder.test.ts: two clones claiming one name both re-read the + * registry before either wrote it, so both answered success and the later write replaced the + * earlier entry. Writes to the registry (and its temp files) are slowed down here, so two writers + * that both read before either writes lose an entry every time instead of once in a while. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { createRouteTestHarness } from './routes/_route-test-utils.js'; +import { registerCaseRoutes } from '../src/web/routes/case-routes.js'; +import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../src/web/linked-cases-writer.js'; +import { isSandboxedHome, safeRmHomeTree } from './mocks/index.js'; + +const LINKED = () => linkedCasesPath(); +const linked = (): Record => JSON.parse(readFileSync(LINKED(), 'utf8')); +/** Temp files the writer left beside the registry (there must never be any). */ +const leftovers = () => + readdirSync(dirname(LINKED())).filter((n) => n.startsWith(`${basename(LINKED())}.`) && n.endsWith('.tmp')); +/** The bytes of a registry torn by two overlapping truncate-then-write calls. */ +const TORN = '{\n "kept": "/srv/kept"\n}' + 'ept-2"\n}'; + +/** How long every write to the registry (or a temp file beside it) is held before it starts. */ +const SLOW_WRITE_MS = 60; +const realWriteFile = fs.writeFile.bind(fs); + +let workDir = ''; + +beforeEach(() => { + if (!isSandboxedHome()) { + throw new Error('linked-cases-writer.test.ts rewrites the linked-cases registry: run it via npm test'); + } + safeRmHomeTree(LINKED()); + mkdirSync(dirname(LINKED()), { recursive: true }); + workDir = realpathSync(mkdtempSync(join(homedir(), 'linked-cases-writer-'))); + vi.spyOn(fs, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => { + if (typeof file === 'string' && file.startsWith(LINKED())) { + await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS)); + } + return realWriteFile(file as string, data as string, options as undefined); + }) as typeof fs.writeFile); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (workDir) safeRmHomeTree(workDir); + workDir = ''; + if (isSandboxedHome()) safeRmHomeTree(LINKED()); +}); + +describe('mutateLinkedCases()', () => { + it('runs concurrent mutations one at a time, so none loses another one’s entry', async () => { + writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' })); + await Promise.all( + Array.from({ length: 8 }, (_, i) => mutateLinkedCases((current) => ({ ...current, [`c${i}`]: `/srv/c${i}` }))) + ); + expect(Object.keys(linked()).sort()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'kept']); + expect(leftovers()).toEqual([]); + }); + + it('a check that throws inside the mutation rejects that caller only and writes nothing', async () => { + const claim = (name: string, path: string) => + mutateLinkedCases((current) => { + if (current[name]) throw Object.assign(new Error(`${name} is taken`), { conflict: true }); + return { ...current, [name]: path }; + }); + const results = await Promise.allSettled([ + claim('twin', '/srv/a'), + claim('twin', '/srv/b'), + claim('solo', '/srv/s'), + ]); + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']); + expect((results[1] as PromiseRejectedResult).reason.message).toBe('twin is taken'); + expect(linked()).toEqual({ twin: '/srv/a', solo: '/srv/s' }); + }); + + it('starts from an empty registry when the file is missing or empty, and writes nothing for undefined', async () => { + const { before, after } = await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' })); + expect(before).toEqual({}); + expect(after).toEqual({ a: '/srv/a' }); + writeFileSync(LINKED(), ' \n'); + await mutateLinkedCases((current) => ({ ...current, b: '/srv/b' })); + expect(linked()).toEqual({ b: '/srv/b' }); + const untouched = readFileSync(LINKED(), 'utf8'); + expect((await mutateLinkedCases(() => undefined)).after).toBeUndefined(); + expect(readFileSync(LINKED(), 'utf8')).toBe(untouched); + }); + + it('refuses a registry that is not a JSON object and leaves it byte for byte', async () => { + for (const content of [TORN, '[]', 'null', '"text"']) { + writeFileSync(LINKED(), content); + const fn = vi.fn((current: Record) => ({ ...current, a: '/srv/a' })); + await expect(mutateLinkedCases(fn)).rejects.toBeInstanceOf(LinkedCasesWriteRefusedError); + expect(fn).not.toHaveBeenCalled(); + expect(readFileSync(LINKED(), 'utf8')).toBe(content); + } + // The chain keeps going after a refusal. + writeFileSync(LINKED(), '{}'); + await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' })); + expect(linked()).toEqual({ a: '/srv/a' }); + }); + + it('leaves the previous registry and no temp file when the write fails', async () => { + writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' })); + const realRename = fs.rename.bind(fs); + vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (to === LINKED()) throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }); + return realRename(from, to); + }); + await expect(mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }))).rejects.toThrow('no space left'); + expect(linked()).toEqual({ kept: '/srv/kept' }); + expect(leftovers()).toEqual([]); + }); + + it('writes through a symlinked registry onto its target and keeps the file’s permissions', async () => { + const real = join(workDir, 'linked-cases.json'); + writeFileSync(real, JSON.stringify({ kept: '/srv/kept' }), { mode: 0o600 }); + safeRmHomeTree(LINKED()); + symlinkSync(real, LINKED()); + await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' })); + expect(lstatSync(LINKED()).isSymbolicLink()).toBe(true); + expect(readlinkSync(LINKED())).toBe(real); + expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({ kept: '/srv/kept', a: '/srv/a' }); + expect(statSync(real).mode & 0o777).toBe(0o600); + expect(readdirSync(workDir).filter((n) => n.endsWith('.tmp'))).toEqual([]); + }); +}); + +describe('the case routes share the writer', () => { + const folder = (name: string) => { + const dir = join(workDir, name); + mkdirSync(dir); + return dir; + }; + + it('two links of different names at once both survive', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const [a, b] = await Promise.all([ + app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'one', path: folder('one') } }), + app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'two', path: folder('two') } }), + ]); + expect([a.json().success, b.json().success]).toEqual([true, true]); + expect(linked()).toEqual({ one: join(workDir, 'one'), two: join(workDir, 'two') }); + }); + + it('two links of one name at once: exactly one wins and the other is told the name is taken', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const [a, b] = await Promise.all([ + app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-a') } }), + app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-b') } }), + ]); + expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1); + const loser = a.json().success ? b : a; + expect(loser.json().errorCode).toBe('ALREADY_EXISTS'); + expect(Object.keys(linked())).toEqual(['twin']); + }); + + it('an unlink at the same time as a link keeps the new link', async () => { + writeFileSync(LINKED(), JSON.stringify({ old: folder('old') })); + const { app } = await createRouteTestHarness(registerCaseRoutes); + const [unlink, link] = await Promise.all([ + app.inject({ method: 'DELETE', url: '/api/cases/old' }), + app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'fresh', path: folder('fresh') } }), + ]); + expect([unlink.json().success, link.json().success]).toEqual([true, true]); + expect(linked()).toEqual({ fresh: join(workDir, 'fresh') }); + expect(existsSync(join(workDir, 'old'))).toBe(true); + }); + + it('a link refuses a registry that does not parse instead of replacing it', async () => { + writeFileSync(LINKED(), TORN); + const { app } = await createRouteTestHarness(registerCaseRoutes); + const res = await app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'n', path: folder('n') } }); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(res.json().error).toContain('is not a valid JSON object'); + expect(readFileSync(LINKED(), 'utf8')).toBe(TORN); + }); +}); diff --git a/test/routes/case-clone-custom-folder.test.ts b/test/routes/case-clone-custom-folder.test.ts index 39e1ac0d..3645414b 100644 --- a/test/routes/case-clone-custom-folder.test.ts +++ b/test/routes/case-clone-custom-folder.test.ts @@ -20,7 +20,7 @@ import { writeFileSync, } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, 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'; @@ -178,6 +178,20 @@ describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', expect(Object.keys(linked())).toEqual(['twin']); }); + it('a registry that is not a JSON object is a 409 that keeps the folder, names it and leaves the file alone', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'refused'); + mkdirSync(dirname(LINKED()), { recursive: true }); + writeFileSync(LINKED(), '[]'); + const res = await clone(app, { name: 'refused', repository: origin, path: target }); + expect(res.statusCode).toBe(409); + expect(res.json().errorCode).toBe('CONFLICT'); + expect(res.json().error).toContain(target); + expect(res.json().error).toContain('Use Link Existing'); + expect(existsSync(join(target, 'README.md'))).toBe(true); + expect(readFileSync(LINKED(), 'utf8')).toBe('[]'); + }); + 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); diff --git a/test/routes/case-custom-path-routes.test.ts b/test/routes/case-custom-path-routes.test.ts index 713eb208..12d05e05 100644 --- a/test/routes/case-custom-path-routes.test.ts +++ b/test/routes/case-custom-path-routes.test.ts @@ -8,7 +8,7 @@ * to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through * safeRmHomeTree, so a run can only ever delete what it created. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync, mkdirSync, @@ -19,6 +19,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; +import fs from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join } from 'node:path'; import { createRouteTestHarness } from './_route-test-utils.js'; @@ -40,7 +41,7 @@ beforeEach(() => { if (!sandboxed()) { throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test'); } - // Recursive: the rollback tests turn the registry into a directory. + // Recursive, so a test that left anything at the registry's path cannot leak into the next one. safeRmHomeTree(LINKED()); // Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private). workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-'))); @@ -134,20 +135,36 @@ describe('POST /api/cases with a custom path', () => { it('undoes what it created when registering fails (a new folder is removed entirely)', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); - mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails - const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') }); + // The registry is replaced by renaming a temp file over it: fail that rename. + const realRename = fs.rename; + const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (to === LINKED()) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + return realRename(from, to); + }); + let res; + try { + res = await create(app, { name: 'doomed', path: join(work(), 'doomed') }); + } finally { + spy.mockRestore(); + } expect(res.statusCode).toBe(500); expect(existsSync(join(work(), 'doomed'))).toBe(false); + expect(linked()).toEqual({}); }); - it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => { + it('undoes only the scaffold inside an empty folder the user picked when the registry is refused', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); mkdirSync(join(work(), 'picked')); - mkdirSync(LINKED(), { recursive: true }); + // A registry that does not parse is never written over (that would unlink every other case). + const torn = '{\n "kept": "/somewhere"\n}tail"\n}'; + writeFileSync(LINKED(), torn); const res = await create(app, { name: 'picked', path: join(work(), 'picked') }); - expect(res.statusCode).toBe(500); + expect(res.statusCode).toBe(409); + expect(res.json().errorCode).toBe('CONFLICT'); + expect(res.json().error).toContain('is not a valid JSON object'); expect(existsSync(join(work(), 'picked'))).toBe(true); expect(readdirSync(join(work(), 'picked'))).toEqual([]); + expect(readFileSync(LINKED(), 'utf8')).toBe(torn); }); it('a request without a path still creates under the cases directory, as before', async () => { diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts index 2a28d08a..b8605955 100644 --- a/test/routes/case-rename-routes.test.ts +++ b/test/routes/case-rename-routes.test.ts @@ -6,7 +6,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -26,6 +25,7 @@ import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js'; import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js'; import { Session } from '../../src/session.js'; import { safeRmHomeTree } from '../mocks/index.js'; +import { mutateLinkedCases } from '../../src/web/linked-cases-writer.js'; import { mutateSettingsFile } from '../../src/web/settings-writer.js'; const LINKED = () => dataPath('linked-cases.json'); @@ -36,6 +36,32 @@ let claudeCfg = ''; const made: string[] = []; const linked = (): Record => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); +/** + * The registry is replaced by renaming a temp file over it, which a read-only file does not stop, so a + * failed registry write is injected at that rename. `before` runs first (another writer landing then). + */ +const failRegistryWrite = (before?: () => Promise, alsoFail?: (from: string) => boolean) => { + const realRename = fs.rename; + return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (to === LINKED()) { + if (before) await before(); + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + if (alsoFail?.(String(from))) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }); + return realRename(from, to); + }); +}; +/** + * Runs `during` right after `folder` moved: past every check the route makes before it writes the + * registry, and before that write starts. Every other rename goes through untouched. + */ +const afterFolderMove = (folder: string, during: () => Promise) => { + const realRename = fs.rename; + return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + await realRename(from, to); + if (from === folder) await during(); + }); +}; const settings = (): Record => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')); const rename = (app: Awaited>['app'], name: string, payload: object) => app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload }); @@ -278,8 +304,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { writeFileSync(LINKED(), JSON.stringify({ proj: real })); writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' })); // the registry reads fine but cannot be written: the rename must undo the folder move - chmodSync(LINKED(), 0o444); - const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + const spy = failRegistryWrite(); + let res; + try { + res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + } finally { + spy.mockRestore(); + } expect(res.statusCode).toBe(500); expect(existsSync(real)).toBe(true); expect(linked()).toEqual({ proj: real }); @@ -294,14 +325,9 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { mkdirSync(real); writeFileSync(LINKED(), JSON.stringify({ proj: real })); writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' })); - chmodSync(LINKED(), 0o444); // Another writer (a settings save) lands after the rename wrote settings.json and before the // registry write fails. - const realWriteFile = fs.writeFile; - const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => { - if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })); - return realWriteFile(file, data, options); - }); + const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }))); try { const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); expect(res.statusCode).toBe(500); @@ -311,6 +337,74 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true }); }); + describe('when the registry changes after the first check', () => { + let real = ''; + type App = Awaited>['app']; + const renameRacing = async (app: App, during: () => Promise) => { + const spy = afterFolderMove(real, during); + try { + return await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + } finally { + spy.mockRestore(); + } + }; + const expectFolderAndSettingsBack = () => { + expect(existsSync(real)).toBe(true); + expect(existsSync(join(work, 'renamed'))).toBe(false); + expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }); + }; + beforeEach(() => { + real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' })); + }); + + it('a name linked meanwhile is 409 EXISTS: the link stands and the rename is undone', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const other = join(work, 'other'); + mkdirSync(other); + const res = await renameRacing(app, () => + app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'renamed', path: other } }) + ); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ errorCode: 'ALREADY_EXISTS', renameCode: 'EXISTS', stoppedSessions: 0 }); + expect(linked()).toEqual({ proj: real, renamed: other }); + expectFolderAndSettingsBack(); + }); + + it('an old entry unlinked meanwhile is 409 BUSY: the rename does not bring it back', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const res = await renameRacing(app, () => app.inject({ method: 'DELETE', url: '/api/cases/proj' })); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY', stoppedSessions: 0 }); + expect(linked()).toEqual({}); + expectFolderAndSettingsBack(); + }); + + it('an old entry pointed at another folder meanwhile is 409 BUSY: that entry is kept', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const other = join(work, 'other'); + const res = await renameRacing(app, () => mutateLinkedCases((current) => ({ ...current, proj: other }))); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' }); + expect(linked()).toEqual({ proj: other }); + expectFolderAndSettingsBack(); + }); + + it('a registry that stopped parsing is refused 409 CONFLICT and left byte for byte', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const torn = `{ "proj": "${real}" }tail"`; + const res = await renameRacing(app, async () => writeFileSync(LINKED(), torn)); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', stoppedSessions: 0 }); + expect(res.json().error).toContain('is not a valid JSON object'); + expect(res.json()).not.toHaveProperty('renameCode'); + expect(readFileSync(LINKED(), 'utf8')).toBe(torn); + expectFolderAndSettingsBack(); + }); + }); + it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); const oldDir = localCase('alpha'); @@ -351,13 +445,8 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { const real = join(work, 'my-project'); mkdirSync(real); writeFileSync(LINKED(), JSON.stringify({ proj: real })); - chmodSync(LINKED(), 0o444); const moved = join(work, 'renamed'); - const realRename = fs.rename; - const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { - if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }); - return realRename(from, to); - }); + const spy = failRegistryWrite(undefined, (from) => from === moved); try { const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); expect(res.statusCode).toBe(500); @@ -373,9 +462,14 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { const real = join(work, 'my-project'); mkdirSync(real); writeFileSync(LINKED(), JSON.stringify({ proj: real })); - chmodSync(LINKED(), 0o444); (ctx.sessions as Map).set('s1', { id: 's1', name: 'w1', workingDir: real }); - const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true }); + const spy = failRegistryWrite(); + let res; + try { + res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true }); + } finally { + spy.mockRestore(); + } expect(res.statusCode).toBe(500); expect(res.json().stoppedSessions).toBe(1); expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename'); diff --git a/test/routes/case-routes.test.ts b/test/routes/case-routes.test.ts index c78bfa0f..68f89679 100644 --- a/test/routes/case-routes.test.ts +++ b/test/routes/case-routes.test.ts @@ -54,6 +54,12 @@ vi.mock('node:fs/promises', () => ({ throw err; }), writeFile: vi.fn(async () => {}), + // What the linked-cases writer needs to replace the registry atomically (temp file + rename). + realpath: vi.fn(async (p: string) => p), + mkdir: vi.fn(async () => undefined), + chmod: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + rm: vi.fn(async () => {}), }, })); @@ -704,6 +710,7 @@ describe('case-routes', () => { }); // linked-cases.json doesn't exist yet mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); const res = await harness.app.inject({ method: 'POST',