mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #421 from Randalix/fix/remote-file-access
fix(files): read remote-case previews, downloads and attachments over ssh
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
File previews, downloads and text reads now work in a **remote (SSH) case**.
|
||||
|
||||
A remote case's working directory is an absolute path on the *remote* host, but the
|
||||
file routes resolved it with local `fs` — so a clicked path (or the File Viewer) always
|
||||
failed as "File not found" even though the file existed and the session was clearly
|
||||
working in that directory. `GET /api/sessions/:id/file-raw`, `file-content`,
|
||||
`file-preview` and `file-thumbnail` now resolve and read through the same
|
||||
`buildSshConnectionArgs()` connection the launch uses (`src/remote-files.ts`, one
|
||||
`realpath`+`stat` probe per request returning both the file and the workspace root).
|
||||
|
||||
Clicked paths that point OUTSIDE the case directory (a remote `/tmp` scratchpad capture,
|
||||
a screenshot elsewhere in the remote home) go through the attachment routes, which had
|
||||
the same local-`fs` assumption: registration, the by-id `raw` stream, the metadata poll
|
||||
and the attachment history list now resolve over ssh as well, so the click-path works
|
||||
whether the file sits inside or outside the case. Which host a record is read from
|
||||
follows the SESSION, never the path string — the same absolute path means a different
|
||||
file on each host, and a remote session never falls back to a local file.
|
||||
|
||||
The guards are unchanged in strength: the workspace boundary is still enforced (now
|
||||
resolved on the host that can actually resolve it), the sensitive-path blocklist and
|
||||
the size cap (`CODEMAN_MAX_DOWNLOAD_BYTES`) still apply before any bytes are read, and
|
||||
`Range` requests keep working, so remote `<video>`/`<audio>` seeking behaves like a
|
||||
local file. An unreachable host is reported as `502` with the remote reason instead of
|
||||
a misleading 404. Nothing is ever copied to the Codeman host.
|
||||
|
||||
Still not available for remote cases, and now said explicitly instead of 404-ing:
|
||||
editing a file (`edit=1` / `PUT` answer 400, the viewer hides its Edit affordance),
|
||||
office-document previews and generated thumbnails (both need the bytes on the server's
|
||||
disk), the file tree / path picker, and `tail-file`. Docker cases are unaffected (their
|
||||
workspace is bind-mounted at the same absolute path).
|
||||
@@ -215,7 +215,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md`
|
||||
|
||||
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-<id>`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **It revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
|
||||
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-<id>`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **It revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ **File reads in a remote case are the second ssh surface** (#415, `src/remote-files.ts`): they go through `buildSshConnectionArgs()` as well, a browser-supplied path is only ever a `shellescape`d token, an unreachable host answers 502 (never 404), the size cap uses the REMOTE size, and no remote file is ever copied onto the server's disk — which is why writes, office previews and thumbnails are deliberately unsupported over ssh. The ATTACHMENT routes (a clicked path outside the case dir) go through the same layer, and which host a record is read from follows the SESSION, never the path string. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
|
||||
|
||||
**Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ A case may instead **ADOPT** a container the user already runs (`DockerCase.owned === false`, mirror of remote-SSH's `owned:false`): Codeman only `exec`s into it and never creates, starts, stops, restarts or removes it, so a missing or stopped container FAILS CLOSED with an actionable message instead of being fixed. Absent = owned, so existing cases are byte-identical. The guarantee is enforced at four independent layers because it cannot be observed by using the feature: `buildDockerStopCommand`/`buildDockerRemoveCommand` throw during pure STRING CONSTRUCTION, `removeDockerContainer` refuses again, drift reports "none" (an adopted container carries no `codeman.confighash` label, so a real comparison would 409 the launch forever), and the boot reaper skips it. ⚠️ Two lifecycle touches the original design missed and that are easy to re-introduce: the full-image export `docker commit`s the container (refused for an adopted case) and the workspace export `docker pause`s it first (skipped — it freezes the owner's processes for the length of the tar). ⚠️ `owned` is applied AFTER `dockerConfigHash`, which takes an explicit field list, or every pre-existing case would trip the drift gate at once. ⚠️ Run modes for a container case come from the CONTAINER (`availableModes`, live-probed): gating the run menu on HOST CLIs (#201) is right for local sessions and wrong here, since a host with no `claude` may run a container that ships one. ⚠️ **A failed probe means opposite things per ownership** — for an ADOPTED case it is a fault worth reporting, for an OWNED one it is the NORMAL state before the first session (the launch chain creates the container), so treating it as a fault hid every agent mode on every freshly linked Docker case behind "start it yourself first". That is why `CaseInfo.docker.owned` is on the wire. ⚠️ Claude is launched WITHOUT `--dangerously-skip-permissions` when the container's exec user is root (Claude Code refuses the flag as root and the refusal is visible only inside the container); which flag to drop is a per-CLI fact, so it is the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is **admin-only in multi-user mode**, unlike `docker-link`: linking creates OUR container, whose one bind mount `isWorkingDirAllowed` has already confined, while an adopted container's mounts belong to its owner and one mounting `/` hands the adopter the host. The same reasoning admin-gates the container listing and the in-container directory browser; the preflight instead admits a non-admin for a container already linked to a case they own, because the run menu probes it for every docker case. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Model is NOT a session field: it is a composition entry in the profile's config
|
||||
|
||||
### Remote SSH cases
|
||||
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`.
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
|
||||
|
||||
### Docker cases
|
||||
|
||||
|
||||
@@ -333,9 +333,12 @@ Out of scope per the issue, and the current behavior already degrades correctly:
|
||||
- **Docker cases**: the workspace is a host directory bind-mounted at the same absolute path, so a host-side
|
||||
write is visible in the container immediately. Edit mode works and needs nothing special. Worth one line
|
||||
in the docs.
|
||||
- **Remote SSH cases**: `workingDir` is a path on the remote host. `validateSessionFilePath` realpaths it
|
||||
locally, which fails, so the write returns 404 exactly like the read routes do today. Confirm the viewer
|
||||
shows a clean empty/error state rather than an unexplained failure, and do not attempt an SFTP path.
|
||||
- **Remote SSH cases**: `workingDir` is a path on the remote host, and the READ routes now
|
||||
resolve it over ssh (`src/remote-files.ts`, same `buildSshConnectionArgs` discipline as the
|
||||
launch path — #415). What stays unsupported is the WRITE side: an `edit=1` / `PUT` answers
|
||||
`400` "editing is not supported for files in a remote (SSH) case", `editable` is always
|
||||
`false`, office previews and generated thumbnails answer `400`, and no remote file is ever
|
||||
copied to the server's disk. Do not attempt an SFTP write path.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -251,6 +251,79 @@ unreachable host answers "unknown", which also means do not revive. The answer
|
||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||
|
||||
## File access over SSH
|
||||
|
||||
A remote case's `workingDir` is an absolute path on the **remote** host
|
||||
(`Session.workingDir = RemoteCase.remotePath`), so the file routes cannot use local
|
||||
`fs`: a local `realpathSync` on a remote-only path fails by construction, which is why
|
||||
previewing a file used to answer `404 File not found` for a case that was working
|
||||
perfectly (#415). `src/remote-files.ts` is the one module that reads remote bytes,
|
||||
and it follows the same rule as the launch path: every ssh command line comes from
|
||||
`buildSshConnectionArgs()` — **never** a hand-built ssh line.
|
||||
|
||||
| Request | What happens |
|
||||
|---------|--------------|
|
||||
| `GET /api/sessions/:id/file-raw` | Streamed over `ssh` (`cat`, or `tail -c +N \| head -c L` for a `Range`); the same 200/206/416 contract as a local file, so `<video>`/`<audio>` seeking works |
|
||||
| `GET /api/sessions/:id/file-content` | `cat` into memory, capped by the existing text limit; `edit=1` answers `400` (see below) and `editable` is always `false` |
|
||||
| `GET /api/sessions/:id/file-preview` | Non-office files redirect to `file-raw` (which works remotely); docx/pptx answer `400` |
|
||||
| `GET /api/sessions/:id/file-thumbnail` | `400` for remote files |
|
||||
| `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there |
|
||||
| `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` |
|
||||
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing` |
|
||||
|
||||
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
|
||||
directory (a remote `/tmp` scratchpad capture, a screenshot elsewhere in the home dir):
|
||||
the frontend's `_isExternalPreviewPath()` sends every absolute path that is not under
|
||||
`workingDir` there, so fixing only `file-raw` would leave exactly that half broken.
|
||||
|
||||
Guard order is deliberately **the same as locally**, and the checks are not weakened
|
||||
by the transport:
|
||||
|
||||
1. Ownership (`findSessionOrFail` / the scope helper) — unchanged.
|
||||
2. Lexical containment of `workingDir + path` — a `../` escape is refused before any
|
||||
connection is opened.
|
||||
3. ONE ssh round trip that returns `realpath` **and** `stat` for the path **and** the
|
||||
workspace root (`remoteProbePaths`). Resolving the root remotely is what keeps the
|
||||
boundary honest for a symlinked `remotePath`; the probe uses `readlink -f` when
|
||||
available and a POSIX `cd`/`pwd -P` fallback otherwise.
|
||||
4. Containment of the remote realpath against the remote root. The sensitive-path
|
||||
blocklist then applies on whichever routes already apply it locally (`/api/download`,
|
||||
attachment registration, edit mode — where resolving symlinks first is what makes it
|
||||
meaningful); the remote branch neither drops a guard the local path has nor invents a
|
||||
stricter one.
|
||||
5. Size cap (`CODEMAN_MAX_DOWNLOAD_BYTES`) applied to the **remote** size, before the
|
||||
body is requested.
|
||||
|
||||
The path arrives from the browser (`?path=`) and is interpolated as a single
|
||||
`shellescape`-quoted token, in a command that is itself shellescaped into the ssh
|
||||
line; `BatchMode=yes` means a host needing a passphrase fails fast instead of hanging.
|
||||
A failed connection is reported as **502** with the remote reason — never a 404, which
|
||||
used to make an unreachable host look like a typo in the agent's output.
|
||||
|
||||
⚠️ **There is deliberately NO local fallback.** A remote case reads the remote bytes or
|
||||
fails, even when a file with the same absolute name exists on the Codeman host — which
|
||||
is the ordinary case for the documented stop-gap workaround, an `sshfs` mount of the
|
||||
remote tree at the identical path. Serving the local twin instead would silently hand
|
||||
back a DIFFERENT filesystem's bytes under a name the user believes is the remote file
|
||||
(a stale mount, a different checkout, a leftover file), and the failure would be
|
||||
invisible. An existing mount therefore stops being load-bearing for previews and
|
||||
downloads but is harmless, and a missing remote file stays a 404 even if the mount
|
||||
still has it.
|
||||
|
||||
**Not available over ssh (by choice, not by accident):** editing a file (writes would
|
||||
need SFTP; `docs/file-viewer-edit-plan.md` §6), office-document previews and
|
||||
generated thumbnails (both need the bytes on the server's disk — no remote file is ever
|
||||
spilled onto the server), the file-tree/picker listings, and `tail-file`. Those routes
|
||||
are still local-only, so with an `sshfs` mount in place they read the mounted copy —
|
||||
the two views can only disagree when that mount is stale. Docker cases are unaffected:
|
||||
their workspace is bind-mounted at the same absolute path, so local `fs` reads real bytes.
|
||||
|
||||
⚠️ A remote record stores the **remote** path, and the same absolute path STRING means a
|
||||
different file on each host. What decides which host to read is therefore never the
|
||||
path but the SESSION (`session.remote`): a remote session never falls back to local
|
||||
`fs`, and a local session never opens an ssh connection — including for attachment
|
||||
records, which are keyed to the session that registered them.
|
||||
|
||||
## API
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
|
||||
+105
-12
@@ -10,10 +10,12 @@ import { randomUUID } from 'node:crypto';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { basename, extname, isAbsolute } from 'node:path';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
import { remoteProbePaths, RemoteFileAccessError } from './remote-files.js';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
|
||||
/**
|
||||
* Playable media extensions, single-sourced here because the WORKSPACE preview
|
||||
@@ -215,6 +217,92 @@ export interface RegisterExternalAttachmentOptions {
|
||||
* `codeman attach` CLI (which POSTs directly when a session id is known).
|
||||
*/
|
||||
forceWorkspaceConfinement?: boolean;
|
||||
/**
|
||||
* Remote (SSH) case: the path exists on the REMOTE host, so it is resolved and
|
||||
* stat'ed there (`remoteProbePaths`) instead of with local `realpathSync`/`fs.stat`,
|
||||
* which cannot see it at all (#415). A file outside the case directory is
|
||||
* unreachable exactly like a file inside it.
|
||||
*
|
||||
* `sessionWorkingDir` must then be the REMOTE path too, and the workspace
|
||||
* confinement check (when active) compares against the remotely canonicalized root,
|
||||
* so a symlinked `remotePath` does not refuse every registration.
|
||||
*/
|
||||
remote?: SessionRemote;
|
||||
}
|
||||
|
||||
/**
|
||||
* A path an attachment request resolved to, on whichever host it lives — the local
|
||||
* filesystem or the remote host of a remote-SSH case. The rest of
|
||||
* {@link registerExternalAttachment} (guards, extension allowlist, registry) is then
|
||||
* host-agnostic: it only ever sees canonical absolute paths and numbers.
|
||||
*/
|
||||
interface ResolvedAttachmentFile {
|
||||
resolvedPath: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
isFile: boolean;
|
||||
extension: string;
|
||||
/** Remote only: the workspace root, with symlinks resolved on the remote host. */
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
/** `extension` the way the attachment registry defines it (no dot, lowercased). */
|
||||
function attachmentExtensionOf(path: string): string {
|
||||
return extname(path).toLowerCase().replace(/^\./, '');
|
||||
}
|
||||
|
||||
/** Local resolution: the historical realpath + stat. */
|
||||
async function resolveLocalAttachment(requestedPath: string): Promise<ResolvedAttachmentFile> {
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(requestedPath);
|
||||
} catch {
|
||||
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
||||
}
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
return {
|
||||
resolvedPath,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs ?? 0,
|
||||
isFile: typeof stat.isFile === 'function' ? stat.isFile() : true,
|
||||
extension: attachmentExtensionOf(resolvedPath),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote resolution for a remote-SSH case: ONE ssh round trip returns the
|
||||
* symlink-resolved path, the size/mtime and the kind, for the file AND (when a
|
||||
* workspace is known) its root, which the confinement check compares against.
|
||||
*/
|
||||
async function resolveRemoteAttachment(
|
||||
requestedPath: string,
|
||||
remote: SessionRemote,
|
||||
sessionWorkingDir?: string
|
||||
): Promise<ResolvedAttachmentFile> {
|
||||
const paths = sessionWorkingDir ? [requestedPath, sessionWorkingDir] : [requestedPath];
|
||||
let probes;
|
||||
try {
|
||||
probes = await remoteProbePaths(remote, paths);
|
||||
} catch (err) {
|
||||
throw new AttachmentRegistrationError(
|
||||
err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable',
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const [probe, rootProbe] = probes;
|
||||
if (!probe) {
|
||||
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedPath: probe.realPath,
|
||||
size: probe.size,
|
||||
mtimeMs: probe.mtimeMs,
|
||||
isFile: probe.kind === 'file',
|
||||
extension: attachmentExtensionOf(probe.realPath),
|
||||
workspaceRoot: rootProbe?.realPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerExternalAttachment(
|
||||
@@ -226,12 +314,9 @@ export async function registerExternalAttachment(
|
||||
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(requestedPath);
|
||||
} catch {
|
||||
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
||||
}
|
||||
const resolved = await (options.remote
|
||||
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir)
|
||||
: resolveLocalAttachment(requestedPath));
|
||||
|
||||
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
||||
// path before doing anything else.
|
||||
@@ -243,7 +328,10 @@ export async function registerExternalAttachment(
|
||||
// the caller forces it for this registration (the magic-link scanner — see
|
||||
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
|
||||
const workingDir = options.sessionWorkingDir;
|
||||
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
|
||||
const confined = options.remote
|
||||
? !!workingDir && isUnderTree(resolved.resolvedPath, resolved.workspaceRoot ?? workingDir)
|
||||
: !!workingDir && !!validateSessionFilePath(workingDir, resolved.resolvedPath);
|
||||
if (!confined) {
|
||||
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||
}
|
||||
}
|
||||
@@ -253,20 +341,25 @@ export async function registerExternalAttachment(
|
||||
// operator-configured extra trees. Symlinks are already resolved above.
|
||||
// Cross-workspace attachment of non-blocked files stays allowed, so
|
||||
// codeman-publish and the ~/.codeman review loop keep working.
|
||||
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
|
||||
//
|
||||
// The list is a pattern list over ABSOLUTE paths, so it is host-agnostic and holds
|
||||
// for a remote path exactly as it does for a local one.
|
||||
if (isBlockedAttachmentPath(resolved.resolvedPath, guard.blockedTrees)) {
|
||||
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||
}
|
||||
|
||||
const extension = extname(resolvedPath).toLowerCase().replace(/^\./, '');
|
||||
const resolvedPath = resolved.resolvedPath;
|
||||
const extension = resolved.extension;
|
||||
if (!isSupportedAttachmentExtension(extension)) {
|
||||
throw new AttachmentRegistrationError('Unsupported attachment type');
|
||||
}
|
||||
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (typeof stat.isFile === 'function' && !stat.isFile()) {
|
||||
if (!resolved.isFile) {
|
||||
throw new AttachmentRegistrationError('Attachment path is not a file');
|
||||
}
|
||||
|
||||
const stat = { size: resolved.size, mtimeMs: resolved.mtimeMs };
|
||||
|
||||
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
|
||||
if (existing) {
|
||||
existing.size = stat.size;
|
||||
|
||||
@@ -13,11 +13,14 @@ import { realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
import { registerExternalAttachment, type AttachmentRegistrationResult } from './attachment-registry.js';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
|
||||
export interface GeneratedArtifactRegistrationOptions {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
sessionWorkingDir: string;
|
||||
/** Remote (SSH) case: the path lives on the remote host (see attachment-registry). */
|
||||
remote?: SessionRemote;
|
||||
}
|
||||
|
||||
export async function registerGeneratedArtifactAttachment(
|
||||
@@ -26,19 +29,30 @@ export async function registerGeneratedArtifactAttachment(
|
||||
// Decide trust on the symlink-resolved path. If it can't be resolved, fall
|
||||
// back to the strict force-confined policy (registration will 404 a missing
|
||||
// file anyway).
|
||||
let forceWorkspaceConfinement = true;
|
||||
try {
|
||||
const resolvedPath = realpathSync(options.filePath);
|
||||
forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(resolvedPath, options.sessionWorkingDir);
|
||||
} catch {
|
||||
// Keep force confinement.
|
||||
}
|
||||
//
|
||||
// A remote case keeps that strict policy unconditionally: the well-known Codex
|
||||
// artifact directories are anchored at THIS host's home, which says nothing about
|
||||
// a remote home, so only a file inside the remote workspace is trusted here.
|
||||
const resolvedPath = options.remote ? undefined : tryRealpath(options.filePath);
|
||||
const forceWorkspaceConfinement = !resolvedPath
|
||||
? true
|
||||
: !isAllowedGeneratedArtifactPath(resolvedPath, options.sessionWorkingDir);
|
||||
return registerExternalAttachment(options.sessionId, options.filePath, {
|
||||
sessionWorkingDir: options.sessionWorkingDir,
|
||||
forceWorkspaceConfinement,
|
||||
remote: options.remote,
|
||||
});
|
||||
}
|
||||
|
||||
/** `realpathSync` without the throw — undefined when the path does not resolve. */
|
||||
function tryRealpath(path: string): string | undefined {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Well-known Codex generated-artifact directories, anchored at the user's home. */
|
||||
function codexGeneratedDirs(): string[] {
|
||||
const home = homedir();
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @fileoverview Remote (SSH) file access for remote-SSH cases.
|
||||
*
|
||||
* A remote case's `workingDir` is an absolute path on ANOTHER host
|
||||
* (`Session.workingDir = RemoteCase.remotePath`, see docs/remote-sessions.md). Every
|
||||
* file route used to read it with local `fs`, which cannot work: the local
|
||||
* `realpathSync` in `validateSessionFilePath` fails first, so the request died as a
|
||||
* 404 "File not found" before a byte was read (#415). This module is the ONE place
|
||||
* that reads remote bytes, mirroring how `remote-hosts.ts` is the one place that
|
||||
* builds an ssh command line.
|
||||
*
|
||||
* Connection options come from `buildSshConnectionArgs()` — never a hand-built ssh
|
||||
* line (the COD-107 discipline in docs/remote-sessions.md) — so a proxied,
|
||||
* custom-port or jump-hosted case reaches its files with exactly the credentials the
|
||||
* launch used, and `BatchMode=yes` means a host that needs a passphrase fails fast
|
||||
* instead of hanging on a prompt nothing can answer.
|
||||
*
|
||||
* ⚠️ The path is the injection surface: it arrives from the browser (`?path=`). It is
|
||||
* always interpolated as a single `shellescape`d token, and the whole remote command
|
||||
* is itself shellescaped into the ssh line, so the local shell and the remote shell
|
||||
* each see one opaque argument. Never build a command here by concatenating a raw
|
||||
* path into the string.
|
||||
*
|
||||
* Read-only by design: previews, text reads and streaming. Writing to a remote file
|
||||
* is deliberately NOT implemented (docs/file-viewer-edit-plan.md §6), nor are the
|
||||
* office-conversion/thumbnail paths that would need the bytes on the server's disk.
|
||||
*/
|
||||
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Bound on the probe (realpath + stat) round trip. The connect itself is already
|
||||
* bounded by `buildSshConnectionArgs`'s default `-o ConnectTimeout=10`; this covers
|
||||
* a host that accepts the TCP connection and then never answers.
|
||||
*/
|
||||
const REMOTE_PROBE_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Bound on a buffered remote read (`cat`), on top of the caller's own size cap. */
|
||||
const REMOTE_READ_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Slack over the caller's byte cap so a file exactly at the limit still fits. */
|
||||
const READ_BUFFER_SLACK_BYTES = 64 * 1024;
|
||||
|
||||
/** Marker a probe prints when the path does not exist on the remote host. */
|
||||
const NOT_FOUND_MARKER = 'n';
|
||||
|
||||
/** What a remote path turned out to be. `other` = symlink/socket/fifo/device. */
|
||||
export type RemotePathKind = 'file' | 'directory' | 'other';
|
||||
|
||||
export interface RemoteProbe {
|
||||
/** The path with symlinks resolved on the REMOTE host. */
|
||||
realPath: string;
|
||||
kind: RemotePathKind;
|
||||
/** Size in bytes (0 for anything that is not a regular file). */
|
||||
size: number;
|
||||
/** mtime in ms since epoch (0 when the remote `stat` reported none). */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote file access failed for a reason that is NOT "the file is missing" —
|
||||
* unreachable host, timeout, ssh error, unexpected probe output. Callers map this to
|
||||
* a 5xx with the remote reason in the message; a missing file is reported separately
|
||||
* as `null`/404 so the two cannot be confused.
|
||||
*/
|
||||
export class RemoteFileAccessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RemoteFileAccessError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a remote shell command in the shared, shellescaped ssh line.
|
||||
*
|
||||
* The single entry point for "run this on the remote host": connection args (port,
|
||||
* identity, jump host, SOCKS ProxyCommand, extra `-o`) all come from
|
||||
* `buildSshConnectionArgs`, and the command is ONE shellescaped token, so a path with
|
||||
* spaces, quotes or `$(…)` cannot escape into the ssh command line.
|
||||
*/
|
||||
export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: string): string {
|
||||
return [...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(shellCommand)].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* `realpath + stat + existence` for one or more paths, in a SINGLE ssh round trip.
|
||||
*
|
||||
* One call instead of three matters: without a shared connection (no ControlMaster)
|
||||
* every extra `ssh` is a fresh handshake, and the file routes need the path AND the
|
||||
* workspace root canonicalized to compare them.
|
||||
*
|
||||
* Each path emits exactly one line — `n` when it does not exist, otherwise
|
||||
* `kind|size|mtime|realPath` with `realPath` LAST so a path containing `|` still
|
||||
* parses (the earlier fields are fixed and the remainder is the path).
|
||||
*
|
||||
* Symlink resolution is portable on purpose: `readlink -f` where available (Linux,
|
||||
* macOS >= 12.3), else the POSIX `cd`/`pwd -P` fallback, which resolves the DIRECTORY
|
||||
* chain. Resolution is required here rather than optional: `isSensitivePath()`
|
||||
* demands an already-realpath'd input, so a remote read must not be able to reach a
|
||||
* blocked target through a symlink any more than a local one can.
|
||||
*/
|
||||
export function buildRemoteProbeCommand(paths: readonly string[]): string {
|
||||
const probes = paths.map((path) => `probe ${shellescape(path)}`).join('\n');
|
||||
return [
|
||||
'probe() {',
|
||||
' p=$1',
|
||||
' r=$(readlink -f "$p" 2>/dev/null) || r=$(cd "$(dirname "$p")" 2>/dev/null && printf %s/%s "$(pwd -P)" "$(basename "$p")")',
|
||||
' [ -n "$r" ] || r=$p',
|
||||
` if [ ! -e "$p" ]; then printf '%s\\n' ${NOT_FOUND_MARKER}; return; fi`,
|
||||
' if [ -d "$r" ]; then t=d; elif [ -f "$r" ]; then t=f; else t=o; fi',
|
||||
' s=0',
|
||||
' if [ "$t" = f ]; then s=$(wc -c < "$r" 2>/dev/null | tr -d " "); [ -n "$s" ] || s=0; fi',
|
||||
' m=$(stat -c %Y "$r" 2>/dev/null || stat -f %m "$r" 2>/dev/null || printf 0)',
|
||||
` printf '%s|%s|%s|%s\\n' "$t" "$s" "$m" "$r"`,
|
||||
'}',
|
||||
probes,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Parse one probe line. `null` for the not-found marker or anything malformed. */
|
||||
export function parseRemoteProbeLine(line: string): RemoteProbe | null {
|
||||
const trimmed = line.replace(/\r$/, '');
|
||||
if (!trimmed || trimmed === NOT_FOUND_MARKER) return null;
|
||||
|
||||
const parts = trimmed.split('|');
|
||||
if (parts.length < 4) return null;
|
||||
|
||||
const [kindRaw, sizeRaw, mtimeRaw] = parts;
|
||||
const kind: RemotePathKind | null =
|
||||
kindRaw === 'f' ? 'file' : kindRaw === 'd' ? 'directory' : kindRaw === 'o' ? 'other' : null;
|
||||
if (!kind) return null;
|
||||
|
||||
const realPath = parts.slice(3).join('|');
|
||||
if (!realPath) return null;
|
||||
|
||||
const size = Number.parseInt(sizeRaw, 10);
|
||||
const mtimeSeconds = Number.parseInt(mtimeRaw, 10);
|
||||
return {
|
||||
realPath,
|
||||
kind,
|
||||
size: Number.isFinite(size) && size > 0 ? size : 0,
|
||||
mtimeMs: Number.isFinite(mtimeSeconds) && mtimeSeconds > 0 ? mtimeSeconds * 1000 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of {@link buildRemoteProbeCommand} into one entry per requested
|
||||
* path, in order. Throws when the output cannot be one line per path — that means the
|
||||
* transport or the remote shell did something unexpected, and silently treating it as
|
||||
* "not found" would turn an infrastructure failure into a wrong 404.
|
||||
*
|
||||
* The LAST `paths.length` lines are used so a login banner or an eager rc-file `echo`
|
||||
* on the remote host cannot shift the alignment.
|
||||
*/
|
||||
export function parseRemoteProbeLines(stdout: string, paths: readonly string[]): Array<RemoteProbe | null> {
|
||||
const lines = stdout.split('\n').filter((line) => line !== '');
|
||||
if (lines.length < paths.length) {
|
||||
throw new RemoteFileAccessError('remote host returned no usable file information');
|
||||
}
|
||||
return lines.slice(-paths.length).map((line) => parseRemoteProbeLine(line));
|
||||
}
|
||||
|
||||
/** Probe one or more remote paths. Entry is `null` for a path that does not exist. */
|
||||
export async function remoteProbePaths(
|
||||
remote: SessionRemote,
|
||||
paths: readonly string[]
|
||||
): Promise<Array<RemoteProbe | null>> {
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteProbeCommand(paths));
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 });
|
||||
stdout = result.stdout;
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(
|
||||
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
||||
);
|
||||
}
|
||||
return parseRemoteProbeLines(stdout, paths);
|
||||
}
|
||||
|
||||
/** Read a whole remote file into memory, capped by `maxBytes`. */
|
||||
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
|
||||
const command = buildRemoteFileCommand(remote, `cat ${shellescape(remotePath)}`);
|
||||
try {
|
||||
const result = await execAsync(command, {
|
||||
timeout: REMOTE_READ_TIMEOUT_MS,
|
||||
maxBuffer: maxBytes + READ_BUFFER_SLACK_BYTES,
|
||||
encoding: 'buffer',
|
||||
});
|
||||
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(`failed to read remote file: ${describeExecError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command that writes a remote file's bytes to stdout.
|
||||
*
|
||||
* ⚠️ Range reads use `tail -c +N | head -c L` (both POSIX, constant memory) because
|
||||
* the alternative — `dd bs=1` — issues one read syscall per byte and would make video
|
||||
* seeking unusable. The trade-off is that a `tail` failure (the file vanished
|
||||
* mid-request) reports `head`'s exit status, i.e. a short body on an already-sent
|
||||
* 206; the client retries. The uncompressed path (`cat`) reports its own failure
|
||||
* correctly, so the streaming error path is still covered by the normal case.
|
||||
*/
|
||||
export function buildRemoteReadCommand(remotePath: string, range?: { start: number; end: number }): string {
|
||||
const quoted = shellescape(remotePath);
|
||||
if (!range) return `cat ${quoted}`;
|
||||
const length = range.end - range.start + 1;
|
||||
return `tail -c +${range.start + 1} ${quoted} | head -c ${length}`;
|
||||
}
|
||||
|
||||
export interface RemoteFileStream {
|
||||
/** The remote file's bytes, streamed from the ssh child's stdout. */
|
||||
stream: Readable;
|
||||
/**
|
||||
* Abort the transfer and reap the ssh child. The caller MUST call this when the
|
||||
* HTTP request ends — especially on a client disconnect — or the `ssh` process
|
||||
* keeps running (and holding a connection open) after nobody is reading it.
|
||||
*/
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a remote file (optionally a byte range) as a Node Readable.
|
||||
*
|
||||
* Nothing is buffered in server memory: the bytes go from `ssh`'s stdout straight to
|
||||
* the HTTP response, which is what makes a multi-GB remote video cost one pipe.
|
||||
*/
|
||||
export function remoteCreateReadStream(
|
||||
remote: SessionRemote,
|
||||
remotePath: string,
|
||||
range?: { start: number; end: number }
|
||||
): RemoteFileStream {
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteReadCommand(remotePath, range));
|
||||
const child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (stderr.length < 2000) stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const stream = child.stdout;
|
||||
let ended = false;
|
||||
stream.on('end', () => {
|
||||
ended = true;
|
||||
});
|
||||
stream.on('error', () => {
|
||||
ended = true;
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
stream.destroy(err);
|
||||
});
|
||||
child.on('close', (code: number | null) => {
|
||||
// Only a truncated transfer is an error. A non-zero exit AFTER the body finished
|
||||
// (e.g. a signal delivered as the last byte was flushed) must not destroy an
|
||||
// already-complete response, or the browser reports a broken body for a file it
|
||||
// received in full.
|
||||
if (ended || code === 0 || code === null) return;
|
||||
const detail = stderr.trim().split('\n')[0];
|
||||
stream.destroy(new RemoteFileAccessError(`remote read failed (ssh exit ${code})${detail ? `: ${detail}` : ''}`));
|
||||
});
|
||||
|
||||
return {
|
||||
stream,
|
||||
close(): void {
|
||||
if (!stream.destroyed) stream.destroy();
|
||||
child.kill('SIGTERM');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** First useful line of an exec/stderr error, for a user-facing message. */
|
||||
function describeExecError(err: unknown): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const record = err as { stderr?: unknown; message?: unknown; code?: unknown; killed?: unknown };
|
||||
const stderr =
|
||||
typeof record.stderr === 'string' ? record.stderr : Buffer.isBuffer(record.stderr) ? String(record.stderr) : '';
|
||||
const line = stderr
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0);
|
||||
if (line) return line;
|
||||
if (record.killed) return 'timed out';
|
||||
if (typeof record.message === 'string' && record.message.length > 0) return record.message;
|
||||
if (typeof record.code === 'string' || typeof record.code === 'number') return `ssh exit ${record.code}`;
|
||||
}
|
||||
return 'unknown error';
|
||||
}
|
||||
+7
-1
@@ -147,8 +147,14 @@ export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): st
|
||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||
* Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a
|
||||
* single shell token. Used here for identity paths and `-o KEY=VALUE` options.
|
||||
*
|
||||
* EXPORTED for `remote-files.ts` (#415, remote file access): that module wraps a
|
||||
* remote shell command in the ssh line built by `buildSshConnectionArgs()`, so it
|
||||
* needs the same escaping discipline for the remote command itself and for every
|
||||
* path interpolated into it. A third private copy of this function is exactly how
|
||||
* two escaping implementations drift apart.
|
||||
*/
|
||||
function shellescape(str: string): string {
|
||||
export function shellescape(str: string): string {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
|
||||
@@ -88,11 +88,43 @@ export function validateSessionFilePath(
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const relativePath = relative(resolvedWorkingDir, resolvedPath);
|
||||
return confineToRoot(resolvedWorkingDir, resolvedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
|
||||
* WITHOUT touching the filesystem.
|
||||
*
|
||||
* Needed for remote-SSH cases (`src/remote-files.ts`), where `workingDir` is an
|
||||
* absolute path on the REMOTE host and any local `realpathSync` fails by
|
||||
* construction — which is how every file-raw/file-content request in a remote case
|
||||
* used to end up as a 404 before a single byte was read. The caller follows this
|
||||
* pre-check with a remote realpath + the same containment rule, so escapes are
|
||||
* refused exactly as they are locally; what changes is only WHICH filesystem
|
||||
* resolves the symlinks.
|
||||
*
|
||||
* A lexical check alone would follow nothing, so it must never be the last word for
|
||||
* a path that can contain a symlink — it is the cheap reject in front of the real
|
||||
* (local or remote) resolution, not a replacement for it.
|
||||
*/
|
||||
export function validateSessionFilePathLexical(
|
||||
sessionWorkingDir: string,
|
||||
filePath: string
|
||||
): { resolvedPath: string; relativePath: string } | null {
|
||||
return confineToRoot(resolve(sessionWorkingDir), resolve(sessionWorkingDir, filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared containment rule: `candidate` must sit inside `root` (both already
|
||||
* canonical for their filesystem). `relative()` is the whole test — a `..` or an
|
||||
* absolute result means the candidate escaped.
|
||||
*/
|
||||
function confineToRoot(root: string, candidate: string): { resolvedPath: string; relativePath: string } | null {
|
||||
const relativePath = relative(root, candidate);
|
||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
return { resolvedPath, relativePath };
|
||||
return { resolvedPath: candidate, relativePath };
|
||||
}
|
||||
|
||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||
|
||||
+573
-103
File diff suppressed because it is too large
Load Diff
@@ -1719,10 +1719,12 @@ export class WebServer extends EventEmitter {
|
||||
sessionId,
|
||||
filePath,
|
||||
sessionWorkingDir: session.workingDir,
|
||||
remote: session.remote,
|
||||
})
|
||||
: await registerExternalAttachment(sessionId, filePath, {
|
||||
sessionWorkingDir: session.workingDir,
|
||||
forceWorkspaceConfinement: true,
|
||||
remote: session.remote,
|
||||
});
|
||||
const record = attachmentRegistry.get(sessionId, event.attachmentId);
|
||||
if (record) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { vi } from 'vitest';
|
||||
import type { SessionStatus } from '../../src/types.js';
|
||||
import type { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
|
||||
|
||||
/**
|
||||
* Enhanced mock session for testing RespawnController.
|
||||
@@ -13,6 +13,18 @@ import type { SessionStatus } from '../../src/types.js';
|
||||
export class MockSession extends EventEmitter {
|
||||
id: string;
|
||||
workingDir: string = '/tmp/test-workdir';
|
||||
/**
|
||||
* Mirrors `Session.remote` — set to a `SessionRemote` to model a remote-SSH case,
|
||||
* whose `workingDir` is an absolute path on ANOTHER host. File routes must read it
|
||||
* over ssh instead of with local `fs` (#415).
|
||||
*/
|
||||
remote?: SessionRemote;
|
||||
/** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */
|
||||
attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
||||
/** Mirrors Session.getAttachmentHistoryForPersist(). */
|
||||
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] {
|
||||
return this.attachmentHistory;
|
||||
}
|
||||
/**
|
||||
* The REAL union, deliberately. This used to be `'idle' | 'working'`, and
|
||||
* `'working'` is not a `SessionStatus` at all — so `signalForStatus()` fell to its
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @fileoverview Tests for remote (SSH) file access (`src/remote-files.ts`).
|
||||
*
|
||||
* Two layers are covered:
|
||||
*
|
||||
* 1. PURE builders/parsers — command construction, escaping and probe parsing, no
|
||||
* connection involved.
|
||||
* 2. The probe SCRIPT itself, executed by a real `/bin/sh` against a real temp
|
||||
* directory. The remote shell is the one place where a quoting mistake becomes an
|
||||
* injection, and it cannot be exercised by an ssh-less unit test any other way: the
|
||||
* script IS the remote command, so `sh -c <script>` reproduces exactly what sshd
|
||||
* runs on the other end.
|
||||
*
|
||||
* Port: N/A (no HTTP server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import {
|
||||
RemoteFileAccessError,
|
||||
buildRemoteFileCommand,
|
||||
buildRemoteProbeCommand,
|
||||
buildRemoteReadCommand,
|
||||
parseRemoteProbeLine,
|
||||
parseRemoteProbeLines,
|
||||
} from '../src/remote-files.js';
|
||||
import type { SessionRemote } from '../src/types/session.js';
|
||||
|
||||
/**
|
||||
* Run a shell line through a real `/bin/sh` and return its `$@` as an argv array,
|
||||
* WITHOUT executing anything. This is how the tests see the exact argument vector a
|
||||
* command line would hand to the process — the local-shell half of the escaping chain.
|
||||
*/
|
||||
function shellArgv(command: string): string[] {
|
||||
const out = execFileSync('sh', ['-c', `set -- ${command}; printf '%s\\0' "$@"`]);
|
||||
// The trailing empty element is the printf format terminator.
|
||||
return out.toString().split('\0').slice(0, -1);
|
||||
}
|
||||
|
||||
/** A remote session fixture; every field is optional in production, so keep it minimal. */
|
||||
function remoteFixture(overrides: Partial<SessionRemote> = {}): SessionRemote {
|
||||
return {
|
||||
hostId: 'host-1',
|
||||
label: 'testhost',
|
||||
host: '192.0.2.10',
|
||||
username: 'j',
|
||||
remotePath: '/srv/case',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildRemoteFileCommand', () => {
|
||||
it('builds the ssh line from the shared connection args and one shellescaped command', () => {
|
||||
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), 'cat /etc/hostname'));
|
||||
|
||||
// buildSshConnectionArgs returns tokens, and the shell re-splits them into the
|
||||
// flags ssh actually wants (`-o` + `BatchMode=yes`), which is what this pins.
|
||||
expect(argv.slice(0, 3)).toEqual(['ssh', '-o', 'BatchMode=yes']);
|
||||
expect(argv).toContain('ConnectTimeout=10');
|
||||
expect(argv).toContain('j@192.0.2.10');
|
||||
// The remote command is ONE argument, whatever it contains.
|
||||
expect(argv[argv.length - 1]).toBe('cat /etc/hostname');
|
||||
expect(argv[argv.length - 2]).toBe('j@192.0.2.10');
|
||||
});
|
||||
|
||||
it('routes port, identity, jump host and extra options through buildSshConnectionArgs', () => {
|
||||
const argv = shellArgv(
|
||||
buildRemoteFileCommand(
|
||||
remoteFixture({
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/id_ed25519',
|
||||
jumpHost: 'bastion.example.com',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
}),
|
||||
'true'
|
||||
)
|
||||
);
|
||||
|
||||
expect(argv).toContain('-p');
|
||||
expect(argv).toContain('2222');
|
||||
expect(argv).toContain('-J');
|
||||
expect(argv).toContain('bastion.example.com');
|
||||
expect(argv).toContain('StrictHostKeyChecking=accept-new');
|
||||
// `~` is expanded before escaping: ssh does not expand it inside -i.
|
||||
expect(argv).toContain(join(homedir(), '.ssh/id_ed25519'));
|
||||
});
|
||||
|
||||
it('keeps a shell-metacharacter command as a single opaque argument', () => {
|
||||
const command = "cat '/tmp/it''s here' ; rm -rf ~ #";
|
||||
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), command));
|
||||
|
||||
expect(argv[argv.length - 1]).toBe(command);
|
||||
expect(argv).not.toContain('rm');
|
||||
expect(argv).not.toContain('-rf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemoteProbeCommand', () => {
|
||||
it('probes every path exactly once, each as its own shell-quoted token', () => {
|
||||
const script = buildRemoteProbeCommand(['/srv/case/a.png', '/srv/case']);
|
||||
const probeCalls = script.split('\n').filter((line) => line.startsWith('probe '));
|
||||
|
||||
expect(probeCalls).toEqual(["probe '/srv/case/a.png'", "probe '/srv/case'"]);
|
||||
});
|
||||
|
||||
it('quotes a path with spaces, quotes and a command substitution', () => {
|
||||
const nasty = "/srv/case/it's $(touch /tmp/pwned).txt";
|
||||
const script = buildRemoteProbeCommand([nasty]);
|
||||
|
||||
expect(script).toContain(`probe '/srv/case/it'\\''s $(touch /tmp/pwned).txt'`);
|
||||
expect(shellArgv(buildRemoteFileCommand(remoteFixture(), script)).at(-1)).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the probe script on a real shell', () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'codeman-remote-probe-'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports kind, size and realpath for a file, a directory and a missing path', () => {
|
||||
const filePath = join(root, 'image.png');
|
||||
writeFileSync(filePath, 'fake png bytes');
|
||||
|
||||
const probes = parseRemoteProbeLines(
|
||||
execFileSync('sh', ['-c', buildRemoteProbeCommand([filePath, root, join(root, 'nope.png')])]).toString(),
|
||||
[filePath, root, join(root, 'nope.png')]
|
||||
);
|
||||
|
||||
expect(probes[0]).toMatchObject({ kind: 'file', size: 14, realPath: filePath });
|
||||
expect(probes[0]?.mtimeMs).toBeGreaterThan(0);
|
||||
expect(probes[1]).toMatchObject({ kind: 'directory', size: 0, realPath: root });
|
||||
expect(probes[2]).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a symlink to its target', () => {
|
||||
const target = join(root, 'target.txt');
|
||||
const link = join(root, 'link.txt');
|
||||
writeFileSync(target, 'x');
|
||||
execFileSync('ln', ['-s', target, link]);
|
||||
|
||||
const [probe] = parseRemoteProbeLines(execFileSync('sh', ['-c', buildRemoteProbeCommand([link])]).toString(), [
|
||||
link,
|
||||
]);
|
||||
|
||||
expect(probe?.realPath).toBe(target);
|
||||
});
|
||||
|
||||
it('treats a hostile filename as data, never as a command', () => {
|
||||
// No slashes in the payload: it has to be a legal FILENAME on this host while
|
||||
// still being a command substitution to a shell.
|
||||
const marker = `codeman_pwned_${process.pid}`;
|
||||
const hostile = join(root, `it's; touch ${marker}; $(id).txt`);
|
||||
writeFileSync(hostile, 'hostile');
|
||||
|
||||
const [probe] = parseRemoteProbeLines(
|
||||
execFileSync('sh', ['-c', buildRemoteProbeCommand([hostile])], { cwd: root }).toString(),
|
||||
[hostile]
|
||||
);
|
||||
|
||||
expect(probe?.realPath).toBe(hostile);
|
||||
expect(existsSync(join(root, marker))).toBe(false);
|
||||
});
|
||||
|
||||
it('handles a path containing the field separator', () => {
|
||||
const pipePath = join(root, 'a|b.txt');
|
||||
writeFileSync(pipePath, 'xy');
|
||||
|
||||
const [probe] = parseRemoteProbeLines(execFileSync('sh', ['-c', buildRemoteProbeCommand([pipePath])]).toString(), [
|
||||
pipePath,
|
||||
]);
|
||||
|
||||
expect(probe?.realPath).toBe(pipePath);
|
||||
expect(probe?.size).toBe(2);
|
||||
});
|
||||
|
||||
it('walks into a nested directory that exists', () => {
|
||||
const nested = join(root, 'sub');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(nested, 'f.txt'), 'abc');
|
||||
|
||||
const [probe] = parseRemoteProbeLines(
|
||||
execFileSync('sh', ['-c', buildRemoteProbeCommand([join(nested, 'f.txt')])]).toString(),
|
||||
[join(nested, 'f.txt')]
|
||||
);
|
||||
|
||||
expect(probe?.size).toBe(3);
|
||||
expect(statSync(join(nested, 'f.txt')).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemoteProbeLine', () => {
|
||||
it('parses a file line and converts mtime to milliseconds', () => {
|
||||
expect(parseRemoteProbeLine('f|1234|1700000000|/srv/case/a.png')).toEqual({
|
||||
realPath: '/srv/case/a.png',
|
||||
kind: 'file',
|
||||
size: 1234,
|
||||
mtimeMs: 1700000000 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a path that itself contains the separator', () => {
|
||||
expect(parseRemoteProbeLine('f|7|0|/srv/ca|se/a b.txt')?.realPath).toBe('/srv/ca|se/a b.txt');
|
||||
});
|
||||
|
||||
it('maps directories, other kinds and the not-found marker', () => {
|
||||
expect(parseRemoteProbeLine('d|0|5|/srv/case')?.kind).toBe('directory');
|
||||
expect(parseRemoteProbeLine('o|0|0|/srv/case/sock')?.kind).toBe('other');
|
||||
expect(parseRemoteProbeLine('n')).toBeNull();
|
||||
expect(parseRemoteProbeLine('')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed lines instead of inventing a path', () => {
|
||||
expect(parseRemoteProbeLine('f|1|2')).toBeNull();
|
||||
expect(parseRemoteProbeLine('x|1|2|/p')).toBeNull();
|
||||
expect(parseRemoteProbeLine('f|1|2|')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemoteProbeLines', () => {
|
||||
it('aligns the last N lines, so a login banner cannot shift the mapping', () => {
|
||||
const stdout = 'welcome to the remote box\nf|3|1|/srv/a.txt\nn\n';
|
||||
expect(parseRemoteProbeLines(stdout, ['/srv/a.txt', '/srv/b.txt'])).toEqual([
|
||||
{ realPath: '/srv/a.txt', kind: 'file', size: 3, mtimeMs: 1000 },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when the remote shell returned too little output', () => {
|
||||
expect(() => parseRemoteProbeLines('f|3|1|/srv/a.txt\n', ['/a', '/b'])).toThrow(RemoteFileAccessError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemoteReadCommand', () => {
|
||||
it('streams the whole file with cat', () => {
|
||||
expect(buildRemoteReadCommand("/srv/case/it's.mp4")).toBe("cat '/srv/case/it'\\''s.mp4'");
|
||||
});
|
||||
|
||||
it('turns a byte range into a constant-memory tail | head', () => {
|
||||
expect(buildRemoteReadCommand('/srv/case/v.mp4', { start: 2, end: 5 })).toBe(
|
||||
"tail -c +3 '/srv/case/v.mp4' | head -c 4"
|
||||
);
|
||||
});
|
||||
|
||||
it('covers the first byte of the file (tail -c +1, not +0)', () => {
|
||||
expect(buildRemoteReadCommand('/f', { start: 0, end: 0 })).toBe("tail -c +1 '/f' | head -c 1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* @fileoverview Route tests for file READ routes in a remote (SSH) case (#415).
|
||||
*
|
||||
* The mirror image of `test/routes/file-routes.test.ts`: every request here resolves
|
||||
* against a path that exists only on another host, so the local `fs` layer must never
|
||||
* be the thing that answers. The ssh layer (`src/remote-files.ts`) is mocked — a test
|
||||
* never opens a connection — but the REAL module is kept alongside the mocks so
|
||||
* `RemoteFileAccessError` and the command builders stay authentic.
|
||||
*
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { attachmentRegistry } from '../../src/attachment-registry.js';
|
||||
import { RemoteFileAccessError } from '../../src/remote-files.js';
|
||||
import type { RemoteProbe } from '../../src/remote-files.js';
|
||||
import type { SessionRemote } from '../../src/types/session.js';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { MAX_FILE_DOWNLOAD_BYTES } from '../../src/config/buffer-limits.js';
|
||||
|
||||
// Keep the pure builders + the error class real; replace only the IO.
|
||||
vi.mock('../../src/remote-files.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
|
||||
return {
|
||||
...actual,
|
||||
remoteProbePaths: vi.fn(),
|
||||
remoteReadFile: vi.fn(),
|
||||
remoteCreateReadStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { remoteProbePaths, remoteReadFile, remoteCreateReadStream } from '../../src/remote-files.js';
|
||||
|
||||
const mockedProbePaths = vi.mocked(remoteProbePaths);
|
||||
const mockedReadFile = vi.mocked(remoteReadFile);
|
||||
const mockedCreateReadStream = vi.mocked(remoteCreateReadStream);
|
||||
|
||||
const REMOTE_DIR = '/srv/remote/case';
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'host-1',
|
||||
label: 'testhost',
|
||||
host: '192.0.2.10',
|
||||
username: 'j',
|
||||
remotePath: REMOTE_DIR,
|
||||
};
|
||||
|
||||
function fileProbe(realPath: string, size: number): RemoteProbe {
|
||||
return { realPath, kind: 'file', size, mtimeMs: 1_700_000_000_000 };
|
||||
}
|
||||
|
||||
const dirProbe: RemoteProbe = { realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 };
|
||||
|
||||
describe('file routes in a remote (SSH) case', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let sessionId: string;
|
||||
let closeSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
sessionId = harness.ctx._sessionId;
|
||||
harness.ctx._session.attachmentHistory = [];
|
||||
// The whole point of the fixture: the workspace is a path on ANOTHER host.
|
||||
harness.ctx._session.workingDir = REMOTE_DIR;
|
||||
harness.ctx._session.remote = { ...remote };
|
||||
|
||||
closeSpy = vi.fn();
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/img.png`, 9), dirProbe]);
|
||||
mockedReadFile.mockResolvedValue(Buffer.from('remote text'));
|
||||
mockedCreateReadStream.mockReturnValue({
|
||||
stream: Readable.from([Buffer.from('remote bytes')]),
|
||||
close: closeSpy,
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The registry is process-global: a record left behind would leak into the next
|
||||
// test's by-id requests.
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/file-raw', () => {
|
||||
it('streams the remote file and probes the path AND the workspace in one call', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.body).toBe('remote bytes');
|
||||
// Both paths in one ssh round trip: the workspace root is needed to check
|
||||
// containment against a REMOTELY canonicalized root.
|
||||
expect(mockedProbePaths).toHaveBeenCalledWith(expect.objectContaining({ host: '192.0.2.10' }), [
|
||||
`${REMOTE_DIR}/img.png`,
|
||||
REMOTE_DIR,
|
||||
]);
|
||||
expect(mockedCreateReadStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: '192.0.2.10' }),
|
||||
`${REMOTE_DIR}/img.png`,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('serves a byte range as a 206 from the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/clip.mp4`, 100), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=clip.mp4`,
|
||||
headers: { range: 'bytes=10-19' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 10-19/100');
|
||||
expect(res.headers['content-length']).toBe('10');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.anything(), `${REMOTE_DIR}/clip.mp4`, {
|
||||
start: 10,
|
||||
end: 19,
|
||||
});
|
||||
});
|
||||
|
||||
it('reaps the ssh stream when the response is done', async () => {
|
||||
await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png` });
|
||||
// The cleanup is registered on the raw response's lifecycle; without it an
|
||||
// aborted download would leave the ssh child running.
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a path that escapes the workspace lexically, without connecting', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=../../etc/shadow`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a symlink that resolves outside the workspace on the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/etc/shadow', 10), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=innocent.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a workspace reached through a remote symlink (both sides canonicalized)', async () => {
|
||||
// remotePath is a symlinked mount: the file's realpath is genuinely inside the
|
||||
// workspace's realpath, so refusing it would break the whole case.
|
||||
harness.ctx._session.workingDir = '/mnt/link/case';
|
||||
mockedProbePaths.mockResolvedValue([
|
||||
fileProbe('/srv/real/case/img.png', 3),
|
||||
{ realPath: '/srv/real/case', kind: 'directory', size: 0, mtimeMs: 0 },
|
||||
]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('404s a file that does not exist on the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([null, dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=gone.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an unreachable host as a gateway failure, not a 404 or a 500', async () => {
|
||||
mockedProbePaths.mockRejectedValue(
|
||||
new RemoteFileAccessError('remote host testhost unreachable: Connection refused')
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(JSON.parse(res.body).error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('applies the size cap to the REMOTE size, before reading', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/huge.mp4`, MAX_FILE_DOWNLOAD_BYTES + 1), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=huge.mp4`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a directory', async () => {
|
||||
mockedProbePaths.mockResolvedValue([dirProbe, dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=.`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('does not touch the ssh layer for a local session', async () => {
|
||||
delete harness.ctx._session.remote;
|
||||
|
||||
await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png` });
|
||||
|
||||
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when a path with the same absolute name ALSO exists on this host', () => {
|
||||
// The case that really happens in practice: the remote tree is mounted on the
|
||||
// Codeman host at the identical absolute path (an sshfs mount, which is the
|
||||
// documented stop-gap workaround for this very bug). The remote host stays the
|
||||
// source of truth: there is deliberately no local fallback, because a fallback
|
||||
// would silently serve the OTHER filesystem's bytes under the same path.
|
||||
let shadowRoot: string;
|
||||
let shadowFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
shadowRoot = mkdtempSync(join(tmpdir(), 'codeman-remote-shadow-'));
|
||||
shadowFile = join(shadowRoot, 'img.png');
|
||||
writeFileSync(shadowFile, 'LOCAL BYTES');
|
||||
harness.ctx._session.workingDir = shadowRoot;
|
||||
harness.ctx._session.remote = { ...remote, remotePath: shadowRoot };
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(shadowFile, 12), { ...dirProbe, realPath: shadowRoot }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(shadowRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serves the REMOTE bytes, never the local copy at the same path', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('remote bytes');
|
||||
expect(res.body).not.toBe('LOCAL BYTES');
|
||||
// The local file is untouched, proving the local side was never the source.
|
||||
expect(readFileSync(shadowFile, 'utf8')).toBe('LOCAL BYTES');
|
||||
});
|
||||
|
||||
it('still 404s when the remote host does not have the file, even though a local one exists', async () => {
|
||||
mockedProbePaths.mockResolvedValue([null, { ...dirProbe, realPath: shadowRoot }]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads text from the remote host, not from the local twin', async () => {
|
||||
const localText = join(shadowRoot, 'notes.txt');
|
||||
writeFileSync(localText, 'local text');
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(localText, 11), { ...dirProbe, realPath: shadowRoot }]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||
});
|
||||
|
||||
expect(JSON.parse(res.body).data.content).toBe('remote text');
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expect.anything(), localText, expect.any(Number));
|
||||
expect(readFileSync(localText, 'utf8')).toBe('local text');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/file-content', () => {
|
||||
it('returns remote text content and never advertises the editor', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/notes.txt`, 11), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.content).toBe('remote text');
|
||||
expect(body.data.editable).toBe(false);
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expect.anything(), `${REMOTE_DIR}/notes.txt`, expect.any(Number));
|
||||
});
|
||||
|
||||
it('classifies remote media by extension without reading it', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/logo.png`, 1024), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=logo.png`,
|
||||
});
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.type).toBe('image');
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
expect(mockedReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns an edit request into an explicit 400 instead of a misleading 404', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/notes.txt`, 11), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt&edit=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not supported for files in a remote');
|
||||
});
|
||||
|
||||
it('reports an unreachable host as a real 502 with the remote reason', async () => {
|
||||
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('timed out');
|
||||
});
|
||||
|
||||
it('rejects a path that escapes the workspace', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=../../../etc/passwd`,
|
||||
});
|
||||
|
||||
expect(JSON.parse(res.body).success).toBe(false);
|
||||
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/file-preview and file-thumbnail', () => {
|
||||
it('redirects a non-office remote file to file-raw', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-preview?path=scan.pdf`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toContain('/file-raw');
|
||||
});
|
||||
|
||||
it('says office previews are unavailable rather than 404-ing', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/doc.docx`, 10), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-preview?path=doc.docx`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toContain('not available for files in a remote');
|
||||
});
|
||||
|
||||
it('says thumbnails are unavailable for a remote file', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/doc.pdf`, 10), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-thumbnail?path=doc.pdf`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toContain('not available for files in a remote');
|
||||
});
|
||||
|
||||
it('reports an unreachable host for previews too', async () => {
|
||||
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: no route'));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-preview?path=doc.docx`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
describe('attachments — a file click OUTSIDE the case directory (#415)', () => {
|
||||
const outsidePath = '/tmp/agent-output/shot.png';
|
||||
|
||||
async function publish(path: string): Promise<{ statusCode: number; body: unknown }> {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/attachments`,
|
||||
payload: { path, notify: false },
|
||||
});
|
||||
return { statusCode: res.statusCode, body: JSON.parse(res.body) };
|
||||
}
|
||||
|
||||
it('registers an out-of-workspace remote path by probing the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
|
||||
const res = await publish(outsidePath);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = (res.body as { data: { attachmentId: string; size: number; fileName: string } }).data;
|
||||
expect(data.attachmentId).toMatch(/^att_/);
|
||||
expect(data.fileName).toBe('shot.png');
|
||||
expect(data.size).toBe(42);
|
||||
// The path is outside the workspace, so a workspace-relative resolution could
|
||||
// never have found it — the probe is what makes this work at all.
|
||||
expect(mockedProbePaths).toHaveBeenCalledWith(expect.objectContaining({ host: '192.0.2.10' }), [
|
||||
outsidePath,
|
||||
REMOTE_DIR,
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves the registered remote attachment's bytes by id, with range support", async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
const published = await publish(outsidePath);
|
||||
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||
|
||||
// The by-id route re-probes (guard defense-in-depth) before streaming.
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/raw`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.body).toBe('remote bytes');
|
||||
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.anything(), outsidePath, undefined);
|
||||
|
||||
const ranged = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/raw`,
|
||||
headers: { range: 'bytes=1-3' },
|
||||
});
|
||||
expect(ranged.statusCode).toBe(206);
|
||||
expect(ranged.headers['content-range']).toBe('bytes 1-3/42');
|
||||
});
|
||||
|
||||
it('reports the remote size in the attachment metadata poll', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
const published = await publish(outsidePath);
|
||||
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 84), dirProbe]);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).data.size).toBe(84);
|
||||
});
|
||||
|
||||
it('404s a remote path that does not exist instead of reporting it as unreadable', async () => {
|
||||
mockedProbePaths.mockResolvedValue([null, dirProbe]);
|
||||
|
||||
const res = await publish('/tmp/agent-output/gone.png');
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.stringify(res.body)).toContain('Attachment file not found');
|
||||
});
|
||||
|
||||
it('reports an unreachable host as 502 for the click path too', async () => {
|
||||
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
|
||||
|
||||
const res = await publish(outsidePath);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
|
||||
it('still refuses a blocked remote path (the blocklist is host-agnostic)', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/etc/shadow', 10), dirProbe]);
|
||||
|
||||
const res = await publish('/etc/shadow');
|
||||
|
||||
// 403 from the guard (the same answer the local path gives for a blocked tree).
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.stringify(res.body)).toMatch(/blocked/i);
|
||||
});
|
||||
|
||||
it('does not offer office previews or thumbnails for a remote attachment', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/tmp/agent-output/report.docx', 10), dirProbe]);
|
||||
const published = await publish('/tmp/agent-output/report.docx');
|
||||
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/tmp/agent-output/report.docx', 10), dirProbe]);
|
||||
const preview = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/preview`,
|
||||
});
|
||||
const thumbnail = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/thumbnail`,
|
||||
});
|
||||
|
||||
expect(preview.statusCode).toBe(400);
|
||||
expect(thumbnail.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('lists an out-of-workspace remote history entry without marking it missing', async () => {
|
||||
harness.ctx._session.attachmentHistory = [
|
||||
{
|
||||
id: 'hist-1',
|
||||
sessionId,
|
||||
fileName: 'shot.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 1,
|
||||
mtimeMs: 1,
|
||||
timestamp: 1,
|
||||
source: 'external',
|
||||
externalPath: outsidePath,
|
||||
},
|
||||
];
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const [item] = JSON.parse(res.body).data.items;
|
||||
expect(item.missing).toBe(false);
|
||||
expect(item.size).toBe(42);
|
||||
expect(item.attachmentId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resolves a workspace-relative history entry over ssh', async () => {
|
||||
harness.ctx._session.attachmentHistory = [
|
||||
{
|
||||
id: 'hist-2',
|
||||
sessionId,
|
||||
fileName: 'out.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 1,
|
||||
mtimeMs: 1,
|
||||
timestamp: 1,
|
||||
source: 'detected',
|
||||
relativePath: 'out.png',
|
||||
},
|
||||
];
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/out.png`, 7), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||
|
||||
const [item] = JSON.parse(res.body).data.items;
|
||||
expect(item.missing).toBe(false);
|
||||
expect(item.size).toBe(7);
|
||||
expect(item.rawUrl).toContain('file-raw');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user