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:
@@ -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`:
|
||||
|
||||
Reference in New Issue
Block a user