feat(stacks): browse and edit mounted volume files in the explorer (#1403)

* feat(stacks): browse and edit mounted volume files in the explorer

Reposition the stack file explorer around runtime configuration access:
discover a stack's declared mounts and expose each as a safe, stack-scoped
file root. The explorer opens on a Volumes group (bind mounts and named
Docker volumes) by default, with the stack source directory as a secondary
group, on a "Files & Volumes" tab.

- Discover roots from the rendered effective compose model; resolve named
  volumes to their Docker name and browse/edit them through the hardened
  helper container, with bind mounts handled directly when reachable.
- Re-derive the allowed roots server-side on every file operation and match
  the client root id against them, so a request can never address a path the
  stack did not declare. Block dangerous host mounts and binds that overlap
  Sencho's managed directories; reject writes to read-only mounts.
- Thread an optional root id through the existing file endpoints and an
  opaque, parseable optimistic-concurrency token through read, conflict,
  and write, for both filesystem and helper backends.
- Keep compose and env file protection on the stack source root only.

* fix(stacks): theme the Files & Volumes root switcher

Replace the raw native select in the file-root switcher with the design
system Select component. The native control did not honour the dark theme,
so the panel rendered white with unreadable text. The themed Select gives a
dark popover with grouped Volumes / Stack source labels and disabled items.

* fix(stacks): contain the bind-root probe and de-taint the file-op error log

Gate the volume-root bind probe's realpath/stat behind a compose-base
containment check (mirroring the storage host-path probe) so they never run
on an unvalidated host path; a source outside the compose dir is unreachable
in the containerized deployment anyway and is reported non-accessible without
touching the filesystem. Log the helper-backed file-op failure through a
constant format string with sanitized arguments instead of an interpolated
template literal.

* fix(stacks): inline the bind-probe containment guard at the fs sinks

The wrapped containment predicate was not recognized as a path barrier, so
the bind probe's realpath/stat still flagged as uncontrolled-data-in-path.
Inline the path.resolve + startsWith check directly at each filesystem sink
(and re-check the resolved canonical before stat, so a within-base symlink
that resolves outside the compose dir is also rejected).

* fix(stacks): harden file-root lifecycle, upload race, and helper errors

Address review findings on the Files & Volumes feature:

- Invalidate the file-root allowlist on stack create/delete/import/from-git
  (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a
  stack deleted and recreated under the same name cannot serve the old stack's
  roots from the 15s cache.
- Use the atomic exclusive write for a non-overwrite upload so a file created
  by another writer after the existence check is not silently clobbered.
- Let the helper's real cd errno through and map permission failures to 403
  consistently across list/stat/read/write/mkdir/delete/pathKind, instead of
  reporting EACCES as 404/500; pathKind no longer reports a permission-denied
  parent as absent.
- Document the realpath-then-open TOCTOU as a known, pre-existing limitation of
  every file op (O_NOFOLLOW is not viable because config volumes legitimately
  contain symlinks); the bind root is contained to the compose dir and the op
  requires stack:edit.
- Docs: drop a missing screenshot reference and correct the protected-file
  delete behavior (stack-root compose/.env cannot be deleted via the explorer).
This commit is contained in:
Anso
2026-06-21 18:16:20 -04:00
committed by GitHub
parent b611f41872
commit b9d8e9f490
24 changed files with 1986 additions and 205 deletions
+103 -35
View File
@@ -1,5 +1,8 @@
import { apiFetch } from './api';
/** The id of the stack source directory root (mirrors the backend constant). */
export const STACK_SOURCE_ROOT_ID = 'stack-source';
/**
* Mirrors backend/src/utils/validation.ts::isValidRelativeStackPath. Client
* defense-in-depth: the backend rejects path-traversal attempts, but catching
@@ -111,22 +114,60 @@ export interface FileContentResult {
size: number;
mime: string;
mtimeMs: number;
/**
* Opaque optimistic-concurrency token, round-tripped verbatim as If-Match on
* save. For stack-source/bind roots it is the weak ETag over the mtime; for
* named-volume roots it is a composite token. Optional for back-compat with a
* server that has not yet been upgraded (falls back to the mtime ETag).
*/
version?: string;
}
/**
* A browsable/editable file root for a stack: the stack source, a bind mount, or
* a named volume. Wire mirror of the backend `StackFileRoot`
* (backend/src/services/StackFileRootsService.ts); keep the two shapes in sync.
*/
export interface FileRootMount {
service: string;
containerPath: string;
readOnly: boolean;
}
export interface FileRoot {
id: string;
kind: 'stack-source' | 'bind' | 'volume';
label: string;
hostPathOrName: string;
mounts: FileRootMount[];
readonly: boolean;
accessible: boolean;
browsable: boolean;
writable: boolean;
chmodable: boolean;
dangerous: boolean;
managedSourceOverlap: boolean;
warning: string | null;
backend: 'fs' | 'helper';
}
/**
* Thrown by writeStackFile when the server reports the target file has been
* modified since the caller's last read (HTTP 412). The current server-side
* content and mtime are attached so callers can prompt the user to reconcile.
* content, mtime, and version token are attached so callers can prompt the user
* to reconcile and retry with the fresh token.
*/
export class FileConflictError extends Error {
readonly code = 'PRECONDITION_FAILED' as const;
readonly currentContent: string;
readonly currentMtimeMs: number;
constructor(message: string, currentContent: string, currentMtimeMs: number) {
readonly currentVersion: string | null;
constructor(message: string, currentContent: string, currentMtimeMs: number, currentVersion: string | null) {
super(message);
this.name = 'FileConflictError';
this.currentContent = currentContent;
this.currentMtimeMs = currentMtimeMs;
this.currentVersion = currentVersion;
}
}
@@ -143,12 +184,25 @@ function stackFilesUrl(stackName: string, suffix: string): string {
return `/stacks/${encodeURIComponent(stackName)}/files${suffix}`;
}
/** `&rootId=...` to append onto an existing query string, or '' for the default stack-source root. */
function rootParam(rootId?: string): string {
return rootId ? `&rootId=${encodeURIComponent(rootId)}` : '';
}
/** Discover the browsable/editable file roots for a stack (Volumes + Stack source). */
export async function listFileRoots(stackName: string): Promise<FileRoot[]> {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/file-roots`);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileRoot[]>;
}
export async function listStackDirectory(
stackName: string,
relPath: string
relPath: string,
rootId?: string,
): Promise<FileEntry[]> {
assertSafeRelPath(relPath);
const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`));
const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileEntry[]>;
}
@@ -156,12 +210,12 @@ export async function listStackDirectory(
export async function readStackFile(
stackName: string,
relPath: string,
options?: { forceText?: boolean }
options?: { forceText?: boolean; rootId?: string }
): Promise<FileContentResult> {
assertSafeRelPath(relPath);
const forceSuffix = options?.forceText ? '&force=text' : '';
const res = await apiFetch(
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${forceSuffix}`)
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${forceSuffix}${rootParam(options?.rootId)}`)
);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileContentResult>;
@@ -169,10 +223,11 @@ export async function readStackFile(
export async function downloadStackFile(
stackName: string,
relPath: string
relPath: string,
rootId?: string,
): Promise<Response> {
assertSafeRelPath(relPath);
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`));
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`));
}
/**
@@ -192,7 +247,7 @@ export async function uploadStackFile(
stackName: string,
targetDir: string,
file: File,
options?: { localOnly?: boolean; overwrite?: boolean }
options?: { localOnly?: boolean; overwrite?: boolean; rootId?: string }
): Promise<void> {
assertSafeRelPath(targetDir, 'target directory');
const fd = new FormData();
@@ -209,7 +264,7 @@ export async function uploadStackFile(
// which breaks multipart boundary negotiation. The 401 side-effects are
// replicated manually below.
const res = await fetch(
`/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}${overwriteSuffix}`)}`,
`/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}${overwriteSuffix}${rootParam(options?.rootId)}`)}`,
{ method: 'POST', credentials: 'include', headers, body: fd }
);
@@ -249,57 +304,67 @@ export async function writeStackFile(
stackName: string,
relPath: string,
content: string,
options?: { ifMatchMtimeMs?: number }
): Promise<{ mtimeMs: number | null }> {
options?: { ifMatchVersion?: string; rootId?: string }
): Promise<{ version: string | null; mtimeMs: number | null }> {
assertSafeRelPath(relPath);
const headers: Record<string, string> = {};
if (options?.ifMatchMtimeMs !== undefined) {
headers['If-Match'] = `"${Math.floor(options.ifMatchMtimeMs)}"`;
if (options?.ifMatchVersion) {
// Send the opaque version token verbatim (it is already a valid quoted
// If-Match value for both fs and helper roots).
headers['If-Match'] = options.ifMatchVersion;
}
const res = await apiFetch(
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`),
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${rootParam(options?.rootId)}`),
{ method: 'PUT', headers, body: JSON.stringify({ content }) }
);
if (res.status === 412) {
let body: { currentContent?: string; currentMtimeMs?: number; error?: string } = {};
let body: { currentContent?: string; currentMtimeMs?: number; currentVersion?: string; error?: string } = {};
try { body = await res.clone().json(); } catch { /* ignore */ }
throw new FileConflictError(
body.error ?? 'File has been modified since you last read it.',
typeof body.currentContent === 'string' ? body.currentContent : '',
typeof body.currentMtimeMs === 'number' ? body.currentMtimeMs : 0,
typeof body.currentVersion === 'string'
? body.currentVersion
: res.headers.get('ETag'),
);
}
if (!res.ok) throw new Error(await parseApiError(res));
// Parse the ETag the server set so callers can update their local mtime.
const etag = res.headers.get('ETag');
if (etag) {
const stripped = etag.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1');
// The ETag is the opaque version token for the new content; round-trip it
// verbatim on the next save. mtimeMs is parsed for display only.
const version = res.headers.get('ETag');
let mtimeMs: number | null = null;
if (version) {
const stripped = version.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1');
const parsed = Number(stripped);
if (Number.isFinite(parsed)) return { mtimeMs: parsed };
if (Number.isFinite(parsed)) mtimeMs = parsed;
}
return { mtimeMs: null };
return { version, mtimeMs };
}
export async function deleteStackPath(
stackName: string,
relPath: string,
recursive?: boolean
recursive?: boolean,
rootId?: string,
): Promise<void> {
assertSafeRelPath(relPath);
const qs = recursive
? `path=${encodeURIComponent(relPath)}&recursive=1`
: `path=${encodeURIComponent(relPath)}`;
const res = await apiFetch(stackFilesUrl(stackName, `?${qs}`), { method: 'DELETE' });
const recursiveSuffix = recursive ? '&recursive=1' : '';
const res = await apiFetch(
stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}${recursiveSuffix}${rootParam(rootId)}`),
{ method: 'DELETE' },
);
if (!res.ok) throw new Error(await parseApiError(res));
}
export async function mkdirStackPath(
stackName: string,
relPath: string
relPath: string,
rootId?: string,
): Promise<void> {
assertSafeRelPath(relPath);
const res = await apiFetch(
stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`),
stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`),
{ method: 'POST', body: JSON.stringify({}) }
);
if (!res.ok) throw new Error(await parseApiError(res));
@@ -308,12 +373,13 @@ export async function mkdirStackPath(
export async function renameStackPath(
stackName: string,
fromRel: string,
toRel: string
toRel: string,
rootId?: string,
): Promise<void> {
assertSafeRelPath(fromRel, 'source path');
assertSafeRelPath(toRel, 'destination path');
const res = await apiFetch(
stackFilesUrl(stackName, '/rename'),
stackFilesUrl(stackName, `/rename${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`),
{ method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
@@ -326,10 +392,11 @@ export interface EntryPermissions {
export async function getStackEntryPermissions(
stackName: string,
relPath: string
relPath: string,
rootId?: string,
): Promise<EntryPermissions> {
assertSafeRelPath(relPath);
const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`));
const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<EntryPermissions>;
}
@@ -337,11 +404,12 @@ export async function getStackEntryPermissions(
export async function setStackEntryPermissions(
stackName: string,
relPath: string,
mode: number
mode: number,
rootId?: string,
): Promise<void> {
assertSafeRelPath(relPath);
const res = await apiFetch(
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`),
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`),
{ method: 'PUT', body: JSON.stringify({ mode }) }
);
if (!res.ok) throw new Error(await parseApiError(res));