Skip to content

feat(services): read-only file browsing inside a deployed service - #1

Closed
DiogoDuart3 wants to merge 1 commit into
mainfrom
feat/service-files
Closed

feat(services): read-only file browsing inside a deployed service#1
DiogoDuart3 wants to merge 1 commit into
mainfrom
feat/service-files

Conversation

@DiogoDuart3

@DiogoDuart3 DiogoDuart3 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

What

Adds a read-only Files tab next to Terminal, so a service's filesystem can be browsed from the dashboard instead of only through a shell. Breadcrumb navigation, directory listing with sizes, text preview, and download.

Why

There is currently no way to look at a deployed container's files from the UI. filesystem.controller.ts looks like it might do this but is a host directory picker for "choose a folder to deploy" — it lists directories only, never file contents, and the dashboard doesn't call it.

Design

Substrate. Built on runtime.inContainerExecutor(), which docker and cloud both implement as sh -c <command> inside the container. One command string serves both; no docker-only path such as getArchive, which cloud has no equivalent for.

Authorization. Same admin tier as the service terminal, with the same 404-shaped denial. A container's filesystem holds its .env, so browsing it is exactly as sensitive as opening a shell in it — gating it lower would let someone denied a shell read everything the shell would have shown them.

Runtime gate: serviceShell. Docker and cloud have it; bare does not, and that exclusion is load-bearing rather than incidental — bare's inContainerExecutor() ignores its containerId and returns the host executor, so a weaker gate would turn this into a host filesystem browser.

Probe protocol. Four invariants, each with tests:

  1. Commands always exit 0. exec rejects on a non-zero exit with stdout as the message, so leaning on exit codes would turn every "file not found" into a 500. Failures are markers on stdout.
  2. Markers are nonce-scoped. Newlines are legal in unix filenames and the probe prints names verbatim, so without a nonce a file could be named to forge ERR denied (whole directory reads as forbidden) or END (listing silently truncated while still reporting success). Each probe mints an unpredictable nonce; a filename cannot contain it.
  3. Every probe is terminated. Docker's exec resolves on stream close and only throws when ExitCode is non-zero — null is falsy — so an early close does not throw, and Node's base64 decoder is lenient about truncated input. Without a terminator plus a length check, a half-read .env would be served looking complete.
  4. Only stdout carries signal. Docker discards stderr while cloud merges it; payloads are base64, whose alphabet has no tab or newline, so stray stderr can neither forge a marker nor corrupt data.

Bounds. 2 MB preview, 10 MB download, 500 entries per directory (which also bounds the per-file wc -c fork count), and a per-user concurrency cap mirroring the terminal's. Over-cap results refuse rather than truncate, and a capped listing is surfaced in the UI — a truncated .env that looks complete is worse than a refusal.

Portability is POSIX sh only — no find -printf, no stat -c, no ls --full-time, none of which exist on busybox, dash and bash alike.

Testing

  • 48 unit tests covering shell quoting (including a round-trip through a real sh for every hostile input), path normalization, marker forgery, truncation detection, size caps and binary sniffing.
  • Full api suite green (3039 passing) and dashboard suite green (485 passing) against main.
  • Verified against real containers on busybox/alpine, dash and bash: hostile filenames (*, -rf, spaces, quotes, embedded newlines), symlinked and broken symlinks, empty directories, /dev/zero and FIFOs (refused instantly rather than hanging), a 138 KB binary round-tripped byte-exact against the container's own md5, and the entry cap.
  • Tab label and UI strings translated across all 9 locales, so the i18n parity ratchet tightens rather than growing.

Notes

  • Read-only by design. No upload, edit, delete or rename.
  • Container-read failures answer 500, not 502: Cloudflare treats a 502 from the origin as a gateway failure and replaces the body with its own error page, so every message would be invisible to an operator behind a CDN.

Rebased notes

Originally written on an older base. Two commits from that line are deliberately not here:

  • A fix for execInContainer using exec.start({hijack: true}), which on Docker 29 makes dockerode reject with (HTTP code 101) unexpected and swallow the response body into the error. main already fixes this — and better, with a timeout and separate stderr capture. Dropped as redundant.
  • A mem_limit change to a compose overlay that only exists on my fork.

Comments referencing the old docker.ts behaviour were corrected against main, and the module's own timeout race was dropped now that the executor honours opts.timeout.

Adds a Files tab next to Terminal — breadcrumb, directory listing with
sizes, text preview and download — plus the endpoints behind it:
GET /api/services/files/:serviceId/{list,read,download}.

Built on runtime.inContainerExecutor(), which docker and cloud both
implement as sh -c inside the container, so one command string serves
both and there is no docker-only path (getArchive) for cloud to lack.

Gated at the service terminal's admin tier with the same 404-shaped
denial: a container's filesystem holds its .env, so browsing it is
exactly as sensitive as opening a shell in it. Gated on the serviceShell
capability, which excludes bare — bare's inContainerExecutor ignores its
containerId and returns the HOST executor, so a weaker gate would make
this a host filesystem browser.

The probe protocol holds four invariants, each with tests:

1. Commands always exit 0. exec rejects on a non-zero exit with stdout as
   the message, so leaning on exit codes would turn every 'file not
   found' into a 500. Failures are markers on stdout.

2. Markers are nonce-scoped. Newlines are legal in filenames and the
   probe prints names verbatim, so without a nonce a file could be NAMED
   to forge 'ERR denied' (whole directory reads as forbidden) or 'END'
   (listing truncated while still reporting success). Each probe mints an
   unpredictable nonce a filename cannot contain.

3. Every probe is terminated, and reads also verify the decoded length
   against the size the container reported. Node's base64 decoder is
   lenient about truncated input, so without both a half-read .env would
   be served looking complete.

4. Only stdout carries signal, and payloads are base64 — an alphabet with
   no tab and no newline — so a stray stderr line can neither forge a
   marker nor corrupt a payload on either runtime.

Reads refuse anything that is not a regular file: without that a FIFO or
character device passes every other guard and 'wc -c < /dev/zero' never
returns, bypassing both size caps.

Bounds: 2MB preview, 10MB download, 500 entries per directory (which also
bounds the per-file fork count), and a per-user concurrency cap mirroring
the terminal's. Over-cap results refuse rather than truncate, and a
capped listing is surfaced in the UI.

Verified against real containers on busybox, dash and bash: hostile
filenames, symlinked and broken symlinks, empty directories, devices and
FIFOs, and a 138KB binary round-tripped byte-exact.

Container-read failures answer 500 rather than 502: Cloudflare treats a
502 from the origin as a gateway failure and replaces the body with its
own error page, so the message would be invisible behind a CDN.

Strings translated across all 9 locales so the i18n parity ratchet
tightens rather than growing.
@DiogoDuart3
DiogoDuart3 changed the base branch from feat/add-this-server-button to main August 10, 2026 17:20
@DiogoDuart3

Copy link
Copy Markdown
Owner Author

Superseded by oblien#545 — same branch, opened against upstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant