mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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.
85 lines
3.5 KiB
TypeScript
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);
|
|
}
|