fix(files): serve remote-case attachments, the path a click takes outside the case

A clicked path that points OUTSIDE the case directory goes through the attachment
routes (the frontend's `_isExternalPreviewPath` sends every absolute path not under
`workingDir` to `POST /attachments`), and those had the same local-`fs` assumption
as file-raw: `realpathSync`/`fs.stat` on a path that only exists on the remote host,
so the file never opened — the case the #415 report was actually about.

- `registerExternalAttachment()` accepts `remote` and resolves through
  `remoteProbePaths` (canonical path, size/mtime, kind, plus the workspace root for
  the confinement check). Everything around it — blocklist, extension allowlist,
  workspace confinement, registry/dedupe — is now shared by both branches, so the
  remote path cannot drift from the local one.
- The by-id routes (`raw`, `preview`, `thumbnail`), the metadata poll and the
  attachment history list resolve over ssh too. `raw` streams with the same
  Range contract as file-raw; `preview` (office) and `thumbnail` answer 400 for a
  remote record; an unreachable host answers 502, a vanished file 404.
- Which host a record is read from follows the SESSION, never the path string: the
  same absolute path is a different file on each host, and a remote session never
  falls back to a local file with that name.
- Codex generated artifacts keep force-workspace confinement for a remote case: the
  well-known artifact directories are anchored at THIS host's home, so only a file
  inside the remote workspace is trusted.

Still local-only by design: writes, office conversion, thumbnails, the file
tree/picker and tail-file.
This commit is contained in:
Randalix
2026-09-14 17:06:42 +02:00
parent 013a5d9cc8
commit 63aafdf274
10 changed files with 566 additions and 86 deletions
+10 -3
View File
@@ -12,6 +12,14 @@ working in that directory. `GET /api/sessions/:id/file-raw`, `file-content`,
`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
@@ -22,6 +30,5 @@ 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, attachment registration for paths outside the
workspace, and `tail-file`. Docker cases are unaffected (their workspace is
bind-mounted at the same absolute path).
disk), the file tree / path picker, and `tail-file`. Docker cases are unaffected (their
workspace is bind-mounted at the same absolute path).
+1 -1
View File
@@ -213,7 +213,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. ⚠️ **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. ⚠️ **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)
+1 -1
View File
@@ -52,7 +52,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`. ⚠️ **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. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, external attachment registration, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.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
+18 -5
View File
@@ -267,6 +267,14 @@ and it follows the same rule as the launch path: every ssh command line comes fr
| `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:
@@ -305,11 +313,16 @@ 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, attachment registration for
paths outside the workspace, 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.
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
+105 -12
View File
@@ -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;
+21 -7
View File
@@ -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();
+228 -56
View File
@@ -216,15 +216,15 @@ function sendFileBody(
async function serveRawFile(
reply: FastifyReply,
resolvedPath: string,
target: FileTarget,
size: number,
fileName: string,
extension: string,
download?: boolean,
rangeHeader?: string | string[]
): Promise<void> {
const stat = await fs.stat(resolvedPath);
if (exceedsDownloadLimit(stat.size)) {
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
if (exceedsDownloadLimit(size)) {
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(size)));
return;
}
// Markup is download-only: served with a renderable type on our own origin it
@@ -240,7 +240,7 @@ async function serveRawFile(
);
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
sendFileBody(reply, stat.size, rangeHeader, localFileSource(resolvedPath));
sendFileBody(reply, size, rangeHeader, fileTargetSource(target));
return;
}
@@ -251,14 +251,14 @@ async function serveRawFile(
reply.header('Content-Type', 'text/plain; charset=utf-8');
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
sendFileBody(reply, stat.size, rangeHeader, localFileSource(resolvedPath));
sendFileBody(reply, size, rangeHeader, fileTargetSource(target));
return;
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
sendFileBody(reply, stat.size, rangeHeader, localFileSource(resolvedPath));
sendFileBody(reply, size, rangeHeader, fileTargetSource(target));
}
function getAttachmentOr404(
@@ -274,6 +274,15 @@ function getAttachmentOr404(
return record;
}
/**
* A registered attachment that passed the guard, plus the remote stat the resolution
* already paid for (absent for a local file, where callers stat it themselves).
*/
interface ServableAttachment {
path: string;
probe?: RemoteProbe;
}
/**
* COD-53 defense-in-depth: refuse to stream a record whose underlying path is
* blocked by the active attachment-guard policy, even though registration
@@ -282,14 +291,23 @@ function getAttachmentOr404(
* record pointing at a symlink that now resolves to a sensitive target is also
* caught; if the path can't be resolved (deleted/unreadable) the check still
* runs on the stored path. When workspace confinement is enabled it additionally
* rejects any record outside the session workspace. Returns true (and sends a
* rejects any record outside the session workspace. Returns null (and sends a
* 403) when blocked.
*
* A remote case resolves the same checks on the remote host (see
* {@link resolveServableRemoteAttachment}); `scope` — not just the working dir — is
* what tells the two apart, because the same absolute path STRING means a different
* file on each host.
*/
async function resolveServableAttachmentPath(
reply: FastifyReply,
record: AttachmentRecord,
sessionWorkingDir?: string
): Promise<string | null> {
scope: SessionFileScope
): Promise<ServableAttachment | null> {
if (scope.remote) {
return resolveServableRemoteAttachment(reply, record, scope);
}
let pathToCheck = record.filePath;
let resolved = false;
try {
@@ -304,7 +322,7 @@ async function resolveServableAttachmentPath(
const blocked =
isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
(guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
(guard.confineToWorkspace && (!scope.workingDir || !validateSessionFilePath(scope.workingDir, pathToCheck)));
if (blocked) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
@@ -313,7 +331,59 @@ async function resolveServableAttachmentPath(
// Serve the freshly-resolved path, not the stored one: if a path component
// became a symlink after registration, the guard checked the resolved target
// but streaming record.filePath would follow the symlink to a swapped file.
return resolved ? pathToCheck : record.filePath;
return { path: resolved ? pathToCheck : record.filePath };
}
/**
* Remote counterpart of {@link resolveServableAttachmentPath}.
*
* The record's stored path was already symlink-resolved on the remote host at
* registration time; re-probing keeps the same defense-in-depth against a path that
* changed into a symlink afterwards, and yields the size/mtime the serving route needs
* anyway — so this costs one ssh round trip, not two.
*
* The blocked-tree list is a pattern list over absolute paths, so it is host-agnostic
* and applies unchanged. An unreachable host is a 502, not a silent "blocked".
*/
async function resolveServableRemoteAttachment(
reply: FastifyReply,
record: AttachmentRecord,
scope: SessionFileScope
): Promise<ServableAttachment | null> {
const remote = scope.remote;
if (!remote) return null;
let probes: Array<RemoteProbe | null>;
try {
probes = await remoteProbePaths(remote, [record.filePath, scope.workingDir]);
} catch (err) {
const detail = err instanceof RemoteFileAccessError ? err.message : getErrorMessage(err);
reply.code(502).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, detail));
return null;
}
const [probe, rootProbe] = probes;
// Unlike the local branch there is no stale-path fallback to fall back TO: the file
// is either on the remote host or it is gone, and the local `fs` was never able to
// answer for it. A vanished attachment answers 404 here (the local path lets its
// stat throw and answers 500 — a historical wart, not worth copying).
if (!probe) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment file not found'));
return null;
}
const guard = await loadAttachmentGuardConfig();
const root = rootProbe?.realPath ?? scope.workingDir;
const blocked =
isBlockedAttachmentPath(probe.realPath, guard.blockedTrees) ||
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
(guard.confineToWorkspace && !isPathWithinRoot(root, probe.realPath));
if (blocked) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return null;
}
return { path: probe.realPath, probe };
}
/**
@@ -911,17 +981,22 @@ function decodeEditableText(buf: Buffer): string {
return text;
}
interface SessionFileHistory {
scope: SessionFileScope;
history: SessionAttachmentHistoryItem[];
}
function getSessionAttachmentHistory(
ctx: SessionPort & ConfigPort,
sessionId: string,
req: FastifyRequest
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
): SessionFileHistory | undefined {
const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId);
if (liveSession) {
if (!canAccessOwned(user, liveSession.owner)) return undefined;
return {
workingDir: liveSession.workingDir,
scope: { workingDir: liveSession.workingDir, remote: liveSession.remote },
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
};
}
@@ -930,7 +1005,7 @@ function getSessionAttachmentHistory(
if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined;
return {
workingDir: stored.workingDir,
scope: { workingDir: stored.workingDir, remote: stored.remote },
history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
};
}
@@ -939,7 +1014,7 @@ function getSessionAttachmentHistory(
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
async function buildDetectedAttachmentRouteItem(
sessionId: string,
workingDir: string,
scope: SessionFileScope,
item: SessionAttachmentHistoryItem
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
@@ -947,19 +1022,44 @@ async function buildDetectedAttachmentRouteItem(
return { ...safe, missing: true };
}
const validated = validateSessionFilePath(workingDir, item.relativePath);
if (!validated) {
return { ...safe, missing: true };
}
const workingDir = scope.workingDir;
let resolvedPath: string;
let size = item.size;
let mtimeMs = item.mtimeMs;
try {
const stat = await fs.stat(validated.resolvedPath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
return { ...safe, missing: true };
if (scope.remote) {
// Same check as the local branch (a workspace-relative entry must still resolve
// inside the workspace), executed on the host that owns the files.
const lexical = validateSessionFilePathLexical(workingDir, item.relativePath);
if (!lexical) return { ...safe, missing: true };
let probes: Array<RemoteProbe | null>;
try {
probes = await remoteProbePaths(scope.remote, [lexical.resolvedPath, workingDir]);
} catch {
// Unreachable host: the entry is not "missing", it is unknown. Reporting it as
// missing would tell the user their file is gone when its host is merely asleep.
return { ...safe, missing: false, size, mtimeMs };
}
const [probe, rootProbe] = probes;
if (!probe || !isPathWithinRoot(rootProbe?.realPath ?? workingDir, probe.realPath)) {
return { ...safe, missing: true };
}
resolvedPath = probe.realPath;
size = probe.size;
mtimeMs = probe.mtimeMs;
} else {
const validated = validateSessionFilePath(workingDir, item.relativePath);
if (!validated) {
return { ...safe, missing: true };
}
resolvedPath = validated.resolvedPath;
try {
const stat = await fs.stat(resolvedPath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
return { ...safe, missing: true };
}
}
const encodedPath = encodeURIComponent(item.relativePath);
@@ -990,7 +1090,7 @@ async function buildDetectedAttachmentRouteItem(
async function buildExternalAttachmentRouteItem(
sessionId: string,
item: SessionAttachmentHistoryItem,
sessionWorkingDir?: string
scope: SessionFileScope
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
if (!item.externalPath) {
@@ -998,7 +1098,10 @@ async function buildExternalAttachmentRouteItem(
}
try {
const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir });
const event = await registerExternalAttachment(sessionId, item.externalPath, {
sessionWorkingDir: scope.workingDir,
remote: scope.remote,
});
return {
...safe,
fileName: event.fileName,
@@ -1215,7 +1318,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
return;
}
await serveRawFile(reply, resolvedPath, fileName, extension, false, req.headers.range);
await serveRawFile(
reply,
{ kind: 'local', resolvedPath, relativePath: '' },
stat.size,
fileName,
extension,
false,
req.headers.range
);
});
// File tree listing
@@ -1898,7 +2009,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
try {
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
// A remote case registers a path that lives on the REMOTE host: the guard and
// the reachability check happen there (#415 — this is the path a clicked
// terminal link takes when the file is OUTSIDE the case directory).
const event = await registerExternalAttachment(id, body.path, {
sessionWorkingDir: session.workingDir,
remote: session.remote,
});
// `notify: false` registers QUIETLY. The file-preview overlay uses it to
// mint an id for a path the user just clicked (a terminal or response-viewer
// link pointing outside the workspace): it is already opening the file, so
@@ -1935,8 +2052,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const items = await Promise.all(
sessionHistory.history.map((item) =>
(item.source === 'external'
? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir)
: buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)
? buildExternalAttachmentRouteItem(id, item, sessionHistory.scope)
: buildDetectedAttachmentRouteItem(id, sessionHistory.scope, item)
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
)
);
@@ -1954,20 +2071,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// size/mtime as the underlying file is rewritten).
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
if (!workingDir) return;
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return;
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return;
const event = attachmentRecordToEvent(record);
let size = record.size;
let mtimeMs = record.mtimeMs;
try {
const stat = await fs.stat(record.filePath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
// File temporarily unavailable mid-write — keep cached values.
if (servable.probe) {
// Remote: the guard re-probe already stat'ed it over ssh — no second round trip.
size = servable.probe.size || record.size;
mtimeMs = servable.probe.mtimeMs || mtimeMs;
} else {
try {
const stat = await fs.stat(record.filePath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
// File temporarily unavailable mid-write — keep cached values.
}
}
return {
success: true,
@@ -1994,14 +2118,34 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const session = findSessionOrFail(ctx, id, req);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
if (!servePath) return;
const servable = await resolveServableAttachmentPath(reply, record, {
workingDir: session.workingDir,
remote: session.remote,
});
if (!servable) return;
try {
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true', req.headers.range);
// A remote record streams over ssh exactly like file-raw, with the same
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
// serving a remote attachment needs no stat the local branch would not also need.
const remote = session.remote;
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
await serveRawFile(
reply,
target,
size,
record.fileName,
record.extension,
download === 'true',
req.headers.range
);
} catch (err) {
reply
.code(500)
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
@@ -2010,12 +2154,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// convert server-side; PDF/PNG/text redirect to the raw route.
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
if (!workingDir) return;
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
if (!servePath) return;
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return;
// Only Office formats need server-side conversion; PDF/PNG and text formats
// (md/txt) preview directly from their raw bytes.
@@ -2024,19 +2168,47 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
return;
}
await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
if (servable.probe) {
// Conversion needs LibreOffice reading the bytes off THIS host's disk, and a
// remote read must never spill remote bytes onto the server (see file-preview).
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Office document preview is not available for files in a remote (SSH) case'
)
);
return;
}
await serveConvertedPreview(reply, servable.path, record.fileName, record.extension);
});
// Serve a first-page thumbnail of a registered attachment by id.
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
if (!workingDir) return;
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
if (!servePath) return;
await serveThumbnail(reply, servePath, record.extension);
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return;
if (servable.probe) {
// Same reason as the office preview: rendering needs the bytes locally.
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Thumbnails are not available for files in a remote (SSH) case'
)
);
return;
}
await serveThumbnail(reply, servable.path, record.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
+2
View File
@@ -1697,10 +1697,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) {
+7 -1
View File
@@ -4,7 +4,7 @@
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { SessionStatus, SessionRemote } from '../../src/types.js';
import type { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -19,6 +19,12 @@ export class MockSession extends EventEmitter {
* 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
+173
View File
@@ -14,6 +14,7 @@ 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';
@@ -62,6 +63,7 @@ describe('file routes in a remote (SSH) case', () => {
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 };
@@ -76,6 +78,9 @@ describe('file routes in a remote (SSH) case', () => {
});
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();
});
@@ -405,4 +410,172 @@ describe('file routes in a remote (SSH) case', () => {
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');
});
});
});