Compare commits

..
Author SHA1 Message Date
Codeman maintainer 012ca2e971 fix(remote): keep Codeman's pane command away from the remote default-shell
Handing the launch to bash verbatim exposed `"${SHELL:-/bin/sh}"` to the far
end for the first time. tmux runs a one-argument command through the remote
default-shell, not /bin/sh as the comments said, and fish refuses `${...}`:
on a fish host, or a bash login with `set -g default-shell fish`, every new
pane died with status 127. master only survived because the local sh had
already rewritten that token to this host's shell path.

Codeman's own text now goes to `/bin/sh -c` as separate tmux arguments, which
tmux execs without a shell. A commands.* override stays one argument and keeps
the default-shell it was written for: under `/bin/sh` a `source` or `[[` in an
override dies with 127 on bash and zsh hosts.

The tests read the argv tmux would receive through a real sh, so they need no
fish: every stock launch must reach /bin/sh and an override must not. The
docker half of bashCArg was pinned by nothing, so it gets its own test.

Comments that described the removed double-quote layer are corrected, and
remote-hosts.ts no longer claims overrides are seeded from the default text
or that they rescue nushell and elvish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:59:57 +01:00
Randalix 21256202ba fix(remote): hand remote/docker launch commands to bash verbatim
respawn-pane runs `bash -c "<ssh …>"` through the local sh, so every `$` in
the remote command is expanded on the Codeman host. withRemoteTmuxPath
(`export PATH="$HOME/.local/bin:…:$PATH"; tmux …`, 5b9445de) therefore sends
the Codeman host's home and PATH to the remote host: a remote user with a
different home (tmux in ~/.nix-profile/bin) gets "tmux: command not found" and
every new remote pane exits 127. `${SHELL:-/bin/sh}` in the remote launch was
the local shell for the same reason.

Remote and docker commands are already quoted for the far end, so they now go
to `bash -c` single-quoted (bashCArg); local commands keep the double-quoted
form. Two tests run the captured respawn-pane line (create and respawn)
through a real sh with a foreign HOME/PATH and check that ssh gets `$HOME` and
`$PATH` unexpanded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 2818e9455489b9acd65841258df4cd035609954c)
2026-11-05 23:35:13 +01:00
Codeman maintainer 46b85fa6d0 chore: bump Playwright to 1.64.1 and unpin the browser suite
Playwright supports Ubuntu 26.04 from 1.61.0, so the browser suite can go
back to ubuntu-latest. Raises the floor in package.json as well, because
the old ^1.58.0 still admitted versions that refuse to install on the
image every workflow now runs on. Bundled Chromium moves from 148 to 156.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:22:41 +01:00
Codeman maintainer aa463d9d28 ci: pin the browser suite to ubuntu-24.04
ubuntu-latest moved to 26.04 and the locked Playwright 1.60.0 refuses to
install chromium there (support starts at 1.61.0), so the nightly stopped
before running a test. Pin to the image of the last green run, so a failure
now points at our code rather than at a new OS and Chromium. Unpin when
Playwright is bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:15:43 +01:00
Codeman maintainer 9eac5ecf01 test: wait for the first read before starting the other edit history sessions
The gate belonged to whichever read arrived first, and sessions load their stores
independently, so under CI timing another session took it, the loop never reached
release() and the test timed out (master, CI run 41930703100). The code cannot block
one session on another: sync() only waits on its own slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:21:12 +01:00
20 changed files with 377 additions and 511 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
Remote sessions: new sessions no longer die with `tmux: command not found` (exit 127) when the remote user's home differs from the Codeman host's. The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` in the remote PATH bootstrap expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, which also means a remote session starts in the remote user's own shell rather than the Codeman host's, and a Codeman host whose `$SHELL` path does not exist on the remote no longer kills the pane. Codeman's own pane command now runs through `/bin/sh` so fish hosts keep working, while a per-host `commands.*` override still runs in the remote user's shell. Behaviour change: in a `commands.*` override, `$` now expands on the far end (remote host or container) instead of on the Codeman host, and a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value is passed to `docker` literally, so write the value out. Thanks to @Randalix for the report and the fix.
+1 -1
View File
@@ -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 `<claude config>/projects/<old>` 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. ⚠️ 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 `{}`), and a check that must still hold when the entry is written (the name is free) 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`.
**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 `<claude config>/projects/<old>` 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.
**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)
+1 -1
View File
@@ -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. 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.
**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 URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` 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.
+18
View File
@@ -133,6 +133,24 @@ Key points:
**login shell**, not the agent.
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
pane command is independently quoted, so a `remotePath` with spaces is safe.
- **That argument reaches the far end verbatim** (#760): the launch goes to the
local `bash -c` in single quotes, so `$HOME`, `$PATH` and `$SHELL` are the
remote user's. In double quotes the local `sh` expanded them first, which sent
this host's home and PATH to the remote. ⚠️ Every `$` in a `commands.*` override
now expands on the far end too. A Docker launch is verbatim as well, so a bare
`$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal
characters: write the value out.
- **tmux picks the shell by argument count** (measured on 3.4): one argument runs
through the remote `default-shell`, two or more are exec'd directly. Codeman's own
pane command is POSIX, so it goes to `/bin/sh -c` as separate arguments, and a fish
login shell (or a `default-shell` set in the remote `~/.tmux.conf`) never parses it:
fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*`
override stays one argument on purpose, since it is written for the user's own shell
(`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`).
- **Login shells**: POSIX shells and fish work. tcsh refuses the `-i -l` pair and needs
a `commands.*` override per mode (`exec claude`). nushell and elvish cannot run the
PATH bootstrap at all, so no override helps. A `remotePath` holding an apostrophe does
not start under fish.
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
preserving historical token order.
+10 -28
View File
@@ -63,7 +63,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
@@ -9736,50 +9736,32 @@
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"node": ">=20"
}
},
"node_modules/pngjs": {
+1 -1
View File
@@ -133,7 +133,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
+1 -1
View File
@@ -392,7 +392,7 @@ export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolea
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
* interpolated into the outer `bash -c "..."` launch layer is escaped through
* interpolated into the `bash -c` launch layer is escaped through
* this so a path with spaces stays a single shell token. Operator-entered fields
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
*/
+14 -10
View File
@@ -66,10 +66,11 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
* SHELL into a pane from its own `default-shell` option, so the command as USED
* here is safe either way (also verified). The default matters because these
* strings are the seed values a per-host `commands.*` override is edited from, and
* nothing constrains where an edited one ends up running. Quoted for a shell path
* containing spaces. `/bin/sh` exists on every POSIX host.
* here is safe either way (also verified). Quoted for a shell path containing
* spaces. `/bin/sh` exists on every POSIX host.
*
* ⚠️ This is POSIX text. fish refuses `${…}` (status 127), so a launch hands it to
* `/bin/sh -c`, never to the remote default-shell (`buildRemoteLaunchCommand`).
*/
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
@@ -80,10 +81,12 @@ const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
* "command not found" (exit 127).
*
* Shells that take neither flag (nushell, elvish, …) cannot be detected from here
* the way `loginShellArgs()` detects them locally, since the shell is whatever the
* REMOTE passwd says. A host like that is what the per-host `commands.*` override
* is for.
* Shells that take neither flag cannot be detected from here the way
* `loginShellArgs()` detects them locally, since the shell is whatever the REMOTE
* passwd says. A host like that is what the per-host `commands.*` override is for
* (tcsh, which rejects `-l` combined with `-i`). It only goes so far: the PATH
* bootstrap below runs in the login shell first, so a shell that cannot parse
* `export` at all (nushell, elvish) cannot be rescued by an override.
*/
export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
@@ -143,8 +146,9 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
* The per-host `commands.*` override remains the escape hatch for every mode.
*/
export function defaultRemoteCommandForMode(mode: SessionMode): string {
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so
// this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files,
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, and
// tmux exports its default-shell as SHELL into the pane, so this launches their actual
// login shell, or the default-shell their tmux.conf picks (zsh, fish, …). `-i -l` so it sources rc files,
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
+45 -18
View File
@@ -1303,10 +1303,12 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
* - The `set` options are scoped per-session (`set -t <name>` / server-level
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
* shell-quoted inside it too. This keeps escaping correct through every layer
* even when the remote path contains spaces.
* runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
* pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
* arguments, and a per-host `commands.*` override stays one argument, which
* tmux hands to the remote default-shell (see the comment at `paneCommand`).
* Each is shell-quoted, so escaping stays correct through every layer even
* when the remote path contains spaces.
*/
export function buildRemoteLaunchCommand(options: {
mode: SessionMode;
@@ -1389,17 +1391,25 @@ export function buildRemoteLaunchCommand(options: {
}
const remoteName = remoteTmuxSessionName(sessionId);
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
// the CLI so the pane PID is the agent itself.
// Innermost: the command tmux runs in the new pane. `exec` replaces the shell
// with the CLI so the pane PID is the agent itself.
//
// tmux runs a ONE-argument command through the remote default-shell, which is
// the user's login shell, and bypasses it for two or more arguments (both
// measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
// a parse error in fish, status 127, a dead pane on every new session), so it
// goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
// An override is the user's own text for THEIR shell: it stays one argument,
// because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
const paneArgv = `${override ? '' : '/bin/sh -c '}${shellescape(paneCommand)}`;
// The tmux command line, with `\;` separating commands so the config `set`s
// apply on the SAME connection (and are idempotent on reattach). Options are
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
// shared remote tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${paneArgv}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
@@ -1574,7 +1584,7 @@ export interface DockerLaunchOptions {
* Build the ONE `bash -c` launch string for a docker session: image-check ->
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
* four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
*/
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
@@ -1602,8 +1612,8 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
} else if (resumeSessionId) {
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
}
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
// pane PID the agent itself.
// Run by tmux through the container's default-shell, so the path is shell-quoted
// here. `exec` makes the pane PID the agent itself.
const paneCommand = `cd ${workdir} && ${modeCommand}`;
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
@@ -1665,11 +1675,10 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const ensure = adopted
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
// ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is
// embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early,
// the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`.
// A `grep -qx` pipeline reads the same answer using only the single-quoted form
// every other line in this builder already uses.
// A `grep -qx` pipeline, in the single-quoted form every other line in this builder
// uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
// a double-quoted layer for a long time, where a `"` closed that string early and tmux
// failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
const start = adopted
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
@@ -2661,7 +2670,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// pane launch in /tmp, then cd inside bash against the current mount table.
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
{
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
@@ -2914,7 +2923,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
{
timeout: EXEC_TIMEOUT_MS,
}
@@ -4663,6 +4672,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
* local sh). A remote or docker command is already quoted for the shell at the far end and
* must reach bash verbatim, so it goes in single quotes. Double quotes (JSON.stringify) let
* the local sh expand every `$` first: the PATH bootstrap of `withRemoteTmuxPath`
* (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this host's home and PATH, so
* a remote user with another home found no tmux and every new remote pane exited 127.
* `${SHELL:-/bin/sh}` in the remote launch was the local shell for the same reason.
* Local commands keep their double-quoted form.
*
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
* as those literal characters.
*/
function bashCArg(launchCmd: string, verbatim: boolean): string {
return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
}
/**
* Shell-escape a string for use as a single argument.
* Wraps in single quotes, escaping any embedded single quotes.
-117
View File
@@ -1,117 +0,0 @@
/**
* @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<string, string>;
/** 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<LinkedCases> {
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<void> {
// 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<unknown> = 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;
}
+54 -64
View File
@@ -86,7 +86,6 @@ 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';
@@ -127,6 +126,7 @@ 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,21 +212,11 @@ function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentC
};
}
/**
* Read and parse linked-cases.json, returning empty object on missing/invalid file. For reads only:
* every write goes through `mutateLinkedCases()`, which re-reads the file inside its own critical
* section and refuses one that does not parse.
*/
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
return readJsonConfig<Record<string, string>>(linkedCasesPath(), 'linked cases', {});
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, '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).
@@ -563,11 +553,15 @@ export function registerCaseRoutes(
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
await writeHooksConfig(casePath);
// 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 };
});
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));
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
@@ -575,14 +569,10 @@ 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 (isLinkedConflict(err)) {
if ((err as { conflict?: boolean }).conflict) {
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));
}
@@ -900,20 +890,20 @@ export function registerCaseRoutes(
}
if (customPath !== undefined) {
// 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.
// 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.
try {
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" was linked while the clone ran`);
return { ...current, [name]: casePath };
});
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));
} catch (err) {
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
const conflict = isLinkedConflict(err);
const refused = err instanceof LinkedCasesWriteRefusedError;
reply.code(conflict || refused ? 409 : 500);
const conflict = (err as { conflict?: boolean }).conflict;
reply.code(conflict ? 409 : 500);
return createErrorResponse(
conflict ? ApiErrorCode.ALREADY_EXISTS : refused ? ApiErrorCode.CONFLICT : ApiErrorCode.OPERATION_FAILED,
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
);
}
@@ -1723,17 +1713,28 @@ export function registerCaseRoutes(
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
// The name check and the write are one step, so two links of one name cannot both succeed.
// 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;
try {
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" is already linked to ${current[name]}`);
return { ...current, [name]: expandedPath };
});
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) {
mkdirSync(codemanDir, { recursive: true });
}
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
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));
}
});
@@ -1814,19 +1815,13 @@ 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 {
// 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;
});
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
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));
}
}
@@ -2012,6 +2007,7 @@ 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<string, unknown> | undefined;
@@ -2031,19 +2027,16 @@ export function registerCaseRoutes(
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
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;
});
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;
}
}
} catch (err) {
// The folder moved but the registries did not follow: put settings.json and the folder back
@@ -2088,9 +2081,6 @@ 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 });
}
});
+9 -9
View File
@@ -878,11 +878,11 @@ const RemoteCommandOverridesSchema = z
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
const noCommandSubstitution = (s: string) => !s.includes('$(');
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
// `remotePath`/`identityFile` are shell-escaped, and a remote launch reaches `bash -c`
// single-quoted (`bashCArg()` in tmux-manager). They are also pasted into strings the
// far end's shells parse, and hosts stored before a launch-quoting change are read with
// no schema, so `$` and backtick (and newline/CR/NUL) stay rejected at the boundary as
// defense in depth, whatever the current quoting does with them.
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
export const RemoteHostSchema = z.object({
@@ -982,10 +982,10 @@ export const RemoteCaseLinkSchema = z.object({
//
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
// any docker-socket mount are structurally unrepresentable (never accepted).
// `containerWorkdir`, and `container` all reach the `bash -c` launch layer, so they
// carry NO_SHELL_META (rejects `$`/backtick) exactly like remotePath/identityFile.
// `--privileged` and any docker-socket mount are structurally unrepresentable
// (never accepted).
const DockerResourceLimitsSchema = z
.object({
+6
View File
@@ -428,14 +428,20 @@ describe('EditHistorySync', () => {
host.append('main', editLine('t1', T0, '/w/a.ts'));
const orig = host.read;
let first = true;
let inRead!: () => void;
const reachedRead = new Promise<void>((r) => (inRead = r));
host.read = async (...a) => {
if (first) {
first = false;
inRead();
await gate;
}
return orig(...a);
};
const slow = sync.sync(local);
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
// could reach the read first, take the gate and hang the loop below before `release()`.
await reachedRead;
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
release();
expect(await slow).toEqual({ state: 'ok' });
-198
View File
@@ -1,198 +0,0 @@
/**
* @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<string, string> => 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<string, string>) => ({ ...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);
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* @fileoverview Which shell parses a remote pane command (#760).
*
* tmux runs a ONE-argument command through the remote default-shell, the user's login shell, and
* bypasses it for two or more arguments. Codeman's own text carries `"${SHELL:-/bin/sh}"`, a parse
* error in fish that kills the pane with status 127, so it must reach `/bin/sh -c` as separate
* arguments. A per-host `commands.*` override is the user's text for their own shell: `source` and
* `[[` work in bash and zsh and die under a dash `/bin/sh`, so it must stay one argument.
*
* CI has no fish, so this reads the argv tmux would receive instead: the real builder's string goes
* through a real sh with `ssh` and `tmux` defined as functions that report their arguments.
*/
import { execFileSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { listClis } from '../src/config/cli-registry/registry.js';
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
import type { SessionMode, SessionRemote } from '../src/types.js';
const SESSION_ID = 'abcd1234-0000-0000-0000-000000000000';
const remote: SessionRemote = {
hostId: 'gpu',
label: 'GPU',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/it is work',
};
/** The one argument ssh would send, as the login shell on the far end receives it. */
function sshArgument(launch: string): string {
return execFileSync('/bin/sh', ['-c', `ssh() { for a; do l="$a"; done; printf '%s' "$l"; }; ${launch}`], {
encoding: 'utf8',
});
}
/** What follows `new-session … -c <dir>`, up to the end of that tmux command. */
function paneArgv(launch: string): string[] {
const argv = execFileSync('/bin/sh', ['-c', `tmux() { printf '%s\\0' "$@"; }; ${sshArgument(launch)}`], {
encoding: 'utf8',
})
.split('\0')
.slice(0, -1);
const head = argv.slice(0, argv.indexOf(';'));
return head.slice(head.indexOf('-c', head.indexOf('new-session')) + 2);
}
const launches: Array<[string, Parameters<typeof buildRemoteLaunchCommand>[0]]> = [
...listClis().map((cli): [string, Parameters<typeof buildRemoteLaunchCommand>[0]] => [
cli.id,
{ mode: cli.id as SessionMode, remote, sessionId: SESSION_ID },
]),
['shell', { mode: 'shell', remote, sessionId: SESSION_ID }],
['claude resume', { mode: 'claude', remote, sessionId: SESSION_ID, resumeSessionId: SESSION_ID }],
['omp resume', { mode: 'omp', remote, sessionId: SESSION_ID, resumeSessionId: 'abc123' }],
];
describe('the shell that parses a remote pane command', () => {
it.each(launches)('%s: Codeman text goes to /bin/sh, never the default-shell', (_name, options) => {
const argv = paneArgv(buildRemoteLaunchCommand(options));
expect(argv.slice(0, 2)).toEqual(['/bin/sh', '-c']);
expect(argv).toHaveLength(3);
expect(argv[2]).toContain(`cd '/home/ubuntu/it is work' && `);
});
it('leaves an override as one argument, parsed by the remote default-shell', () => {
const override = 'source ~/env.sh && [[ -x ~/.local/bin/codx ]] && exec codx';
const argv = paneArgv(
buildRemoteLaunchCommand({
mode: 'codex',
remote: { ...remote, commands: { codex: override } },
sessionId: SESSION_ID,
})
);
expect(argv).toEqual([`cd '/home/ubuntu/it is work' && ${override}`]);
});
it('keeps the rest of the tmux chain behind the longer pane command', () => {
const sent = sshArgument(buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: SESSION_ID }));
expect(sent).toContain(String.raw` \; set -t codeman-ssh-abcd1234 status off \; `);
expect(sent).toMatch(/set -t codeman-ssh-abcd1234 remain-on-exit failed$/);
});
});
+1 -1
View File
@@ -159,7 +159,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
const path = sh('/home/ubuntu/work');
const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`;
const tmuxInvocation = [
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} /bin/sh -c ${sh(paneCommand)}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`,
+7 -24
View File
@@ -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, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
mkdirSync,
@@ -19,7 +19,6 @@ 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';
@@ -41,7 +40,7 @@ beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive, so a test that left anything at the registry's path cannot leak into the next one.
// Recursive: the rollback tests turn the registry into a directory.
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-')));
@@ -135,36 +134,20 @@ 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);
// 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();
}
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
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 when the registry is refused', async () => {
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
// 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);
mkdirSync(LINKED(), { recursive: true });
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('is not a valid JSON object');
expect(res.statusCode).toBe(500);
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 () => {
+17 -31
View File
@@ -6,6 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -35,21 +36,6 @@ let claudeCfg = '';
const made: string[] = [];
const linked = (): Record<string, string> => (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<unknown>, 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);
});
};
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
@@ -292,13 +278,8 @@ 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
const spy = failRegistryWrite();
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
} finally {
spy.mockRestore();
}
chmodSync(LINKED(), 0o444);
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real });
@@ -313,9 +294,14 @@ 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 spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
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);
});
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
@@ -365,8 +351,13 @@ 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 spy = failRegistryWrite(undefined, (from) => from === moved);
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);
});
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
@@ -382,14 +373,9 @@ 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<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const spy = failRegistryWrite();
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
} finally {
spy.mockRestore();
}
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
expect(res.statusCode).toBe(500);
expect(res.json().stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
-7
View File
@@ -54,12 +54,6 @@ 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 () => {}),
},
}));
@@ -710,7 +704,6 @@ 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',
+106
View File
@@ -28,6 +28,20 @@ import type { MuxSession } from '../src/mux-interface.js';
// ============================================================================
// Mock child_process
/**
* Runs a captured `… respawn-pane … bash -c <arg>` line through a real local sh (with a
* foreign HOME/PATH) and returns the string bash would get, i.e. what ssh sends on.
*/
async function bashArgAfterLocalShell(respawnCmd: string): Promise<string> {
const real = await vi.importActual<typeof import('node:child_process')>('node:child_process');
const at = respawnCmd.indexOf(' bash -c ');
expect(at).toBeGreaterThan(0);
return real.execFileSync('/bin/sh', ['-c', `printf '%s' ${respawnCmd.slice(at + ' bash -c '.length)}`], {
env: { HOME: '/local-home-xyz', PATH: '/local-path-xyz:/usr/bin:/bin' },
encoding: 'utf8',
});
}
vi.mock('node:child_process', async () => {
const actual = await vi.importActual('node:child_process');
return {
@@ -865,6 +879,98 @@ describe('TmuxManager (unit)', () => {
}
});
it('hands a remote launch to ssh with $HOME and $PATH unexpanded', async () => {
// respawn-pane runs `bash -c <arg>` through the local sh. In double quotes that sh
// expanded withRemoteTmuxPath's `$HOME/…:$PATH` to the Codeman host's values, so a
// remote user with another home found no tmux and the pane exited 127.
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'abc12345-1234-5678-90ab-cdef12345679',
workingDir: '/w',
mode: 'shell',
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
});
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
.map(([cmd]) => cmd)
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
const sent = await bashArgAfterLocalShell(launch as string);
expect(sent).toContain('ssh ');
expect(sent).toContain('export PATH="$HOME/');
expect(sent).toContain(':$PATH"; tmux ');
expect(sent).not.toContain('local-home-xyz');
expect(sent).not.toContain('local-path-xyz');
} finally {
nonTestManager.destroy();
}
});
it('respawns a remote pane with $HOME unexpanded as well', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
nonTestManager.registerSession({
sessionId: 'respawnremote2',
muxName: 'codeman-abcd8888',
pid: 1000,
createdAt: Date.now(),
workingDir: '/w',
mode: 'shell',
attached: false,
});
try {
await nonTestManager.respawnPane({
sessionId: 'respawnremote2',
workingDir: '/w',
mode: 'shell',
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
});
const { exec: currentExec } = await import('node:child_process');
const respawnCall = vi
.mocked(currentExec)
.mock.calls.find(([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
const sent = await bashArgAfterLocalShell(respawnCall?.[0] as string);
expect(sent).toContain('export PATH="$HOME/');
expect(sent).not.toContain('local-home-xyz');
} finally {
nonTestManager.destroy();
}
});
it('hands a docker override to bash with $HOME unexpanded', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'abc12345-1234-5678-90ab-cdef12345680',
workingDir: '/w',
mode: 'shell',
docker: {
hostId: 'h',
label: 'L',
engine: 'docker',
image: 'codeman/agent:base',
containerName: 'codeman-case-x',
hostWorkspacePath: '/w',
containerWorkdir: '/w',
commands: { shell: 'echo CONTAINER_HOME=$HOME; exec sleep 30' },
} as never,
});
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
.map(([cmd]) => cmd)
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
const sent = await bashArgAfterLocalShell(launch as string);
expect(sent).toContain('docker');
expect(sent).toContain('CONTAINER_HOME=$HOME');
expect(sent).not.toContain('local-home-xyz');
} finally {
nonTestManager.destroy();
}
});
it('changes the global history default on tmux versions that cannot resize panes', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();