Files
Codeman/src/generated-artifact-attachments.ts
T
Randalix 63aafdf274 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.
2026-09-14 17:06:42 +02:00

85 lines
3.5 KiB
TypeScript

/**
* @fileoverview Codex generated-artifact attachment registration.
*
* Codex image generation prints paths such as `Saved to: file://...`. These
* paths are registered directly when they fall within allowed locations (the
* session workspace or the well-known Codex generated-artifact directories
* anchored at the user's home). The trust decision is made on the
* realpath-RESOLVED path so a symlink staged at an allowed location cannot
* smuggle an arbitrary host file past workspace confinement.
*/
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(
options: GeneratedArtifactRegistrationOptions
): Promise<AttachmentRegistrationResult> {
// 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).
//
// 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();
return [
join(home, '.codex-personal', 'generated_images'),
join(home, '.codex', 'generated_images'),
join(home, '.codex-personal', 'generated_artifacts'),
join(home, '.codex', 'generated_artifacts'),
];
}
/**
* True when `filePath` (absolute; callers should pass the realpath-resolved
* path) is inside the session workspace or one of the well-known Codex
* generated-artifact directories under the current user's home. The marker
* directories are prefix-anchored to `os.homedir()` — a `.codex/...` subtree
* elsewhere on the filesystem does NOT qualify.
*/
export function isAllowedGeneratedArtifactPath(filePath: string, workingDir: string): boolean {
const normalizedPath = normalize(filePath);
if (isPathInside(normalizedPath, workingDir)) return true;
return codexGeneratedDirs().some((dir) => isPathInside(normalizedPath, dir));
}
function isPathInside(filePath: string, rootPath: string): boolean {
const normalizedRoot = normalize(rootPath);
if (filePath === normalizedRoot) return true;
return filePath.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : normalizedRoot + sep);
}