mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
+105
-12
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user