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
+237
View File
@@ -0,0 +1,237 @@
/**
* FileRootGateway: one uniform interface over the two storage backends behind a
* stack file root. Stack-source and bind roots run on FileSystemService (the
* `fs` backend); named-volume roots run on VolumeBrowserService (the `helper`
* backend, a hardened Alpine container). The route layer resolves a
* StackFileRoot, then calls the gateway so each handler does not re-implement
* the fs-vs-helper branch or the response-shape mapping.
*
* Optimistic concurrency is carried as an opaque, quoted `version` token that is
* a valid If-Match/ETag value end-to-end: for fs roots it is the existing weak
* ETag `W/"<mtimeMs>"` (so stack-source concurrency is unchanged); for helper
* roots it is a composite mtime+size+hash token that distinguishes two edits
* within the same (seconds-resolution) second.
*/
import type { Readable } from 'stream';
import { FileSystemService, type FileEntry, type FileRootScope } from './FileSystemService';
import { VolumeBrowserService, makeHelperVersion, type VolumeEntry } from './VolumeBrowserService';
import type { StackFileRoot } from './StackFileRootsService';
const HELPER_VIEW_MAX_BYTES = 2 * 1024 * 1024; // match the stack-source viewer cap
export interface GatewayReadResult {
content?: string;
binary: boolean;
oversized: boolean;
size: number;
mime: string;
mtimeMs: number;
version: string;
}
export type GatewayWriteResult =
| { ok: true; mtimeMs: number; version: string }
| { ok: false; currentContent: string; currentMtimeMs: number; currentVersion: string };
/** fs version token: the existing weak ETag over the integer mtimeMs. */
export function makeFsVersion(mtimeMs: number): string {
return `W/"${Math.floor(mtimeMs)}"`;
}
/** Parse the millisecond mtime out of a quoted (optionally weak) numeric ETag token. */
export function parseFsVersion(raw: string | undefined): number | null {
if (!raw) return null;
const m = /(?:W\/)?"(\d+)"/.exec(raw);
if (!m) return null;
const value = Number(m[1]);
return Number.isFinite(value) ? value : null;
}
function volumeEntryToFileEntry(e: VolumeEntry): FileEntry {
return {
name: e.name,
type: e.type === 'other' ? 'file' : e.type,
size: e.size,
mtime: e.mtime * 1000,
isProtected: false,
};
}
export class FileRootGateway {
private nodeId: number;
private constructor(nodeId: number) {
this.nodeId = nodeId;
}
static getInstance(nodeId: number): FileRootGateway {
return new FileRootGateway(nodeId);
}
private fs(): FileSystemService {
return FileSystemService.getInstance(this.nodeId);
}
private helper(): VolumeBrowserService {
return VolumeBrowserService.getInstance(this.nodeId);
}
/** fs scope for a stack-source/bind root; bind roots disable compose/.env protection. */
private scopeFor(root: StackFileRoot): FileRootScope {
return root.kind === 'stack-source'
? { protectedEnabled: true }
: { rootAbsDir: root.hostPathOrName, protectedEnabled: false };
}
/** Composite helper version token from an ms mtime + size + the file bytes. */
private helperVersion(mtimeMs: number, size: number, bytes: Buffer): string {
return makeHelperVersion(Math.floor(mtimeMs / 1000), size, bytes);
}
async listDir(
root: StackFileRoot,
stackName: string,
relPath: string,
limit: number,
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
if (root.backend === 'helper') {
const entries = (await this.helper().listDir(root.hostPathOrName, relPath)).map(volumeEntryToFileEntry);
return { entries, total: entries.length, truncated: false };
}
return this.fs().listStackDirectoryPage(stackName, relPath, { limit, scope: this.scopeFor(root) });
}
async read(
root: StackFileRoot,
stackName: string,
relPath: string,
forceText: boolean,
): Promise<GatewayReadResult> {
if (root.backend === 'helper') {
const r = await this.helper().readFile(root.hostPathOrName, relPath, { maxBytes: HELPER_VIEW_MAX_BYTES });
const bytes = Buffer.from(r.content, r.encoding);
const showContent = !r.binary && !r.truncated;
return {
content: showContent ? r.content : undefined,
binary: r.binary,
oversized: r.truncated,
size: r.size,
mime: r.mime,
mtimeMs: r.mtimeMs,
version: this.helperVersion(r.mtimeMs, r.size, bytes),
};
}
const r = await this.fs().readStackFile(stackName, relPath, undefined, { forceText, scope: this.scopeFor(root) });
return { ...r, version: makeFsVersion(r.mtimeMs) };
}
/** Optimistic-concurrency write for the editor save path. */
async writeIfUnchanged(
root: StackFileRoot,
stackName: string,
relPath: string,
content: string,
expectedVersion: string | undefined,
): Promise<GatewayWriteResult> {
if (root.backend === 'helper') {
const volume = root.hostPathOrName;
const exists = (await this.helper().pathKind(volume, relPath)) === 'file';
if (exists && expectedVersion) {
const current = await this.helper().readFile(volume, relPath, { maxBytes: HELPER_VIEW_MAX_BYTES });
const currentBytes = Buffer.from(current.content, current.encoding);
const currentVersion = this.helperVersion(current.mtimeMs, current.size, currentBytes);
if (currentVersion !== expectedVersion) {
return {
ok: false,
currentContent: current.binary ? '' : current.content,
currentMtimeMs: current.mtimeMs,
currentVersion,
};
}
}
const written = await this.helper().writeFile(volume, relPath, Buffer.from(content, 'utf-8'));
return {
ok: true,
mtimeMs: written.mtimeMs,
version: this.helperVersion(written.mtimeMs, written.size, Buffer.from(content, 'utf-8')),
};
}
const expectedMtimeMs = parseFsVersion(expectedVersion);
const result = await this.fs().writeStackFileIfUnchanged(stackName, relPath, content, expectedMtimeMs, this.scopeFor(root));
if (result.ok) return { ok: true, mtimeMs: result.mtimeMs, version: makeFsVersion(result.mtimeMs) };
return {
ok: false,
currentContent: result.currentContent,
currentMtimeMs: result.currentMtimeMs,
currentVersion: makeFsVersion(result.currentMtimeMs),
};
}
async pathKind(root: StackFileRoot, stackName: string, relPath: string): Promise<'file' | 'directory' | null> {
if (root.backend === 'helper') return this.helper().pathKind(root.hostPathOrName, relPath);
return this.fs().pathKind(stackName, relPath, this.scopeFor(root));
}
/** Upload write. `exclusive` rejects an existing target (no overwrite). */
async writeBuffer(
root: StackFileRoot,
stackName: string,
relPath: string,
buffer: Buffer,
exclusive: boolean,
): Promise<void> {
if (root.backend === 'helper') {
if (exclusive && (await this.helper().pathKind(root.hostPathOrName, relPath)) !== null) {
throw Object.assign(new Error('File already exists'), { code: 'FILE_EXISTS' });
}
await this.helper().writeFile(root.hostPathOrName, relPath, buffer);
return;
}
await this.fs().writeStackFileBuffer(stackName, relPath, buffer, { exclusive, scope: this.scopeFor(root) });
}
async download(
root: StackFileRoot,
stackName: string,
relPath: string,
): Promise<{ kind: 'stream'; stream: Readable; size: number; filename: string; mime: string }
| { kind: 'buffer'; buffer: Buffer; size: number; filename: string }> {
if (root.backend === 'helper') {
const d = await this.helper().downloadFile(root.hostPathOrName, relPath);
return { kind: 'buffer', buffer: d.buffer, size: d.size, filename: d.filename };
}
const s = await this.fs().streamStackFile(stackName, relPath, this.scopeFor(root));
return { kind: 'stream', stream: s.stream, size: s.size, filename: s.filename, mime: s.mime };
}
async deletePath(root: StackFileRoot, stackName: string, relPath: string, recursive: boolean): Promise<void> {
if (root.backend === 'helper') return this.helper().deletePath(root.hostPathOrName, relPath, recursive);
return this.fs().deleteStackPath(stackName, relPath, recursive, this.scopeFor(root));
}
async mkdir(root: StackFileRoot, stackName: string, relPath: string): Promise<void> {
if (root.backend === 'helper') return this.helper().mkdir(root.hostPathOrName, relPath);
return this.fs().mkdirStackPath(stackName, relPath, this.scopeFor(root));
}
async rename(root: StackFileRoot, stackName: string, fromRel: string, toRel: string): Promise<void> {
if (root.backend === 'helper') return this.helper().rename(root.hostPathOrName, fromRel, toRel);
return this.fs().renameStackPath(stackName, fromRel, toRel, this.scopeFor(root));
}
async getMode(root: StackFileRoot, stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
if (root.backend === 'helper') throw unsupportedOnHelperRoot();
return this.fs().getStackEntryMode(stackName, relPath, this.scopeFor(root));
}
async chmod(root: StackFileRoot, stackName: string, relPath: string, mode: number): Promise<void> {
if (root.backend === 'helper') throw unsupportedOnHelperRoot();
return this.fs().chmodStackPath(stackName, relPath, mode, this.scopeFor(root));
}
}
/** Permissions (chmod) are not supported on a helper-backed named-volume root. */
function unsupportedOnHelperRoot(): Error & { code: string } {
return Object.assign(new Error('Permissions are not editable on a named volume'), { code: 'UNSUPPORTED_ON_ROOT' });
}