Files
Codeman/src/generated-artifact-attachments.ts
T
Codeman maintainer 13c877f938 fix(review): harden Codex generated-artifact attachment pipeline (PR #150)
- Pass the attachment request `source` through the server deps lambda and make
  it a required param on SessionListenerDeps.registerAttachment + the wiring
  event type (the 2-arg lambda silently dropped `source`, force-confining every
  codex-generated artifact — the feature never worked outside the workspace);
  new test/session-listener-wiring.test.ts asserts the pass-through
- Gate the Codex `Saved to: file://` scanner on mode === 'codex' via a
  codexArtifacts option threaded from the session call site; magic links stay
  mode-agnostic; tests assert claude/shell sessions never emit codex-generated
  requests
- Decide the generated-artifact trust policy on the realpath-RESOLVED path
  (unresolvable → force-confined) and anchor the ~/.codex marker dirs to
  os.homedir() prefixes with startsWith instead of substring matching; symlink
  escape + unanchored-marker regression tests added
- Run the Codex scanner on stripAnsi'd data so trailing SGR sequences don't
  ride into the captured URL; styled 'Saved to:' test added
- Extend generateFirstPageThumbnail with jpg/jpeg/gif/webp passthrough and
  per-extension content types (mirrors the png passthrough) so the PR's new
  image formats render real thumbnails instead of 204 letter-tiles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:50:21 +02:00

71 lines
2.8 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';
export interface GeneratedArtifactRegistrationOptions {
sessionId: string;
filePath: string;
sessionWorkingDir: string;
}
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).
let forceWorkspaceConfinement = true;
try {
const resolvedPath = realpathSync(options.filePath);
forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(resolvedPath, options.sessionWorkingDir);
} catch {
// Keep force confinement.
}
return registerExternalAttachment(options.sessionId, options.filePath, {
sessionWorkingDir: options.sessionWorkingDir,
forceWorkspaceConfinement,
});
}
/** 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);
}