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
+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) {