mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
This commit is contained in:
+22
-3
@@ -5,6 +5,7 @@
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { stripAnsi } from './utils/index.js';
|
||||
|
||||
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
|
||||
const CODEX_SAVED_FILE_RE = /\bSaved to:\s*(file:\/\/[^\s<>"']+)/gi;
|
||||
@@ -14,15 +15,30 @@ export interface TerminalAttachmentRequest {
|
||||
source: 'external' | 'codex-generated';
|
||||
}
|
||||
|
||||
export interface ParseTerminalAttachmentOptions {
|
||||
/**
|
||||
* Enable the Codex `Saved to: file://...` scanner. Only codex-mode sessions
|
||||
* may set this — the relaxed codex-generated trust policy must never be
|
||||
* reachable from other modes' (prompt-injectable) terminal output.
|
||||
*/
|
||||
codexArtifacts?: boolean;
|
||||
}
|
||||
|
||||
export function parseAttachmentMagicLinks(data: string): string[] {
|
||||
return parseMagicAttachmentRequests(data).map((request) => request.path);
|
||||
}
|
||||
|
||||
export function parseTerminalAttachmentRequests(data: string): TerminalAttachmentRequest[] {
|
||||
export function parseTerminalAttachmentRequests(
|
||||
data: string,
|
||||
options: ParseTerminalAttachmentOptions = {}
|
||||
): TerminalAttachmentRequest[] {
|
||||
const results: TerminalAttachmentRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
const requests = options.codexArtifacts
|
||||
? [...parseMagicAttachmentRequests(data), ...parseCodexGeneratedArtifactRequests(data)]
|
||||
: parseMagicAttachmentRequests(data);
|
||||
|
||||
for (const request of [...parseMagicAttachmentRequests(data), ...parseCodexGeneratedArtifactRequests(data)]) {
|
||||
for (const request of requests) {
|
||||
const key = `${request.source}:${request.path}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
@@ -59,7 +75,10 @@ function parseCodexGeneratedArtifactRequests(data: string): TerminalAttachmentRe
|
||||
const results: TerminalAttachmentRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const match of data.matchAll(CODEX_SAVED_FILE_RE)) {
|
||||
// Codex styles its TUI output — strip ANSI first so a trailing SGR reset
|
||||
// (e.g. `...mockup.png\x1b[0m`) doesn't ride into the captured URL and break
|
||||
// the extension allowlist check.
|
||||
for (const match of stripAnsi(data).matchAll(CODEX_SAVED_FILE_RE)) {
|
||||
const rawUrl = trimTrailingPunctuation(match[1] || '');
|
||||
try {
|
||||
const filePath = fileURLToPath(rawUrl);
|
||||
|
||||
@@ -13,9 +13,18 @@ import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
/** Browser-renderable image formats served as-is (no conversion). */
|
||||
const IMAGE_PASSTHROUGH_CONTENT_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
export interface ThumbnailResult {
|
||||
content: Buffer;
|
||||
contentType: 'image/png';
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
|
||||
@@ -24,8 +33,9 @@ export async function generateFirstPageThumbnail(filePath: string, extension: st
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
|
||||
if (ext === 'png') {
|
||||
return { content: await fs.readFile(filePath), contentType: 'image/png' };
|
||||
const passthroughContentType = IMAGE_PASSTHROUGH_CONTENT_TYPES[ext];
|
||||
if (passthroughContentType) {
|
||||
return { content: await fs.readFile(filePath), contentType: passthroughContentType };
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
* @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 (workspace
|
||||
* or well-known Codex generated-image directories).
|
||||
* 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 { posix as posixPath } from 'node:path';
|
||||
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';
|
||||
|
||||
const CODEX_GENERATED_DIR_MARKERS = [
|
||||
'/.codex-personal/generated_images/',
|
||||
'/.codex/generated_images/',
|
||||
'/.codex-personal/generated_artifacts/',
|
||||
'/.codex/generated_artifacts/',
|
||||
];
|
||||
|
||||
export interface GeneratedArtifactRegistrationOptions {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
@@ -25,25 +23,48 @@ export interface GeneratedArtifactRegistrationOptions {
|
||||
export async function registerGeneratedArtifactAttachment(
|
||||
options: GeneratedArtifactRegistrationOptions
|
||||
): Promise<AttachmentRegistrationResult> {
|
||||
const forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(options.filePath, options.sessionWorkingDir);
|
||||
// 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 = posixPath.normalize(filePath);
|
||||
const normalizedPath = normalize(filePath);
|
||||
if (isPathInside(normalizedPath, workingDir)) return true;
|
||||
return CODEX_GENERATED_DIR_MARKERS.some((marker) => normalizedPath.includes(marker));
|
||||
return codexGeneratedDirs().some((dir) => isPathInside(normalizedPath, dir));
|
||||
}
|
||||
|
||||
function isPathInside(filePath: string, rootPath: string): boolean {
|
||||
const normalizedRoot = ensureTrailingSlash(posixPath.normalize(rootPath));
|
||||
const normalizedPath = posixPath.normalize(filePath);
|
||||
return normalizedPath === normalizedRoot.slice(0, -1) || normalizedPath.startsWith(normalizedRoot);
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
return value.endsWith('/') ? value : `${value}/`;
|
||||
const normalizedRoot = normalize(rootPath);
|
||||
if (filePath === normalizedRoot) return true;
|
||||
return filePath.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : normalizedRoot + sep);
|
||||
}
|
||||
|
||||
+5
-3
@@ -1231,9 +1231,11 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Scan terminal output for attachment requests. `codeman://attach?...` is an
|
||||
// explicit magic link; Codex generated images report `Saved to: file://...`.
|
||||
// The web server applies the trust boundary for each request source.
|
||||
const attachmentRequests = parseTerminalAttachmentRequests(data);
|
||||
// explicit magic link (all modes); Codex generated images report
|
||||
// `Saved to: file://...` — that scanner (and its relaxed trust policy) is
|
||||
// only enabled for codex-mode sessions. The web server applies the trust
|
||||
// boundary for each request source.
|
||||
const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: this.mode === 'codex' });
|
||||
for (const request of attachmentRequests) {
|
||||
const seenKey = `${request.source}:${request.path}`;
|
||||
if (this._attachmentMagicSeen.has(seenKey)) continue;
|
||||
|
||||
+9
-4
@@ -1330,7 +1330,8 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
getStore: () => this.store,
|
||||
registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath),
|
||||
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
||||
this.registerAttachment(id, filePath, source),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1343,13 +1344,17 @@ export class WebServer extends EventEmitter {
|
||||
* session workspace — passive magic links can't expose arbitrary host files.
|
||||
* Deliberate cross-workspace attachment goes through the explicit,
|
||||
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
|
||||
* directly inside a managed session). Registration also enforces the COD-53
|
||||
* blocklist as defense-in-depth.
|
||||
* directly inside a managed session). Codex-mode `Saved to:` requests
|
||||
* (`source: 'codex-generated'`) instead go through
|
||||
* registerGeneratedArtifactAttachment, which stays force-confined unless the
|
||||
* realpath-resolved target is inside the workspace or a home-anchored
|
||||
* `~/.codex*` generated-artifact directory. Registration also enforces the
|
||||
* COD-53 blocklist as defense-in-depth.
|
||||
*/
|
||||
private async registerAttachment(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
source: 'external' | 'codex-generated' = 'external'
|
||||
source: 'external' | 'codex-generated'
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface SessionListenerRefs {
|
||||
bashToolStart: (tool: ActiveBashTool) => void;
|
||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
attachmentRequested: (event: { path: string; source?: 'external' | 'codex-generated' }) => void;
|
||||
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||
@@ -78,7 +78,7 @@ interface SessionListenerDeps {
|
||||
removeSessionListenerRefs(sessionId: string): void;
|
||||
cleanupRespawnOnExit(sessionId: string): void;
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
registerAttachment(sessionId: string, filePath: string, source?: 'external' | 'codex-generated'): Promise<void>;
|
||||
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,7 +359,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
},
|
||||
|
||||
/** Registers an explicit attachment card requested by terminal magic text. */
|
||||
attachmentRequested: (event: { path: string; source?: 'external' | 'codex-generated' }) => {
|
||||
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => {
|
||||
deps.registerAttachment(session.id, event.path, event.source).catch((err) => {
|
||||
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,8 @@ describe('attachment magic links', () => {
|
||||
|
||||
it('extracts Codex generated image file URLs from saved-to terminal output', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/mockup%20one.png'
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/mockup%20one.png',
|
||||
{ codexArtifacts: true }
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
@@ -69,6 +70,28 @@ describe('attachment magic links', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores Codex saved-to output unless the codex scanner is enabled', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/mockup.png'
|
||||
);
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips ANSI styling around Codex saved-to lines before capturing the URL', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'\x1b[1mSaved to:\x1b[0m file:///Users/aamer/.codex/generated_images/mockup.png\x1b[0m\r\n',
|
||||
{ codexArtifacts: true }
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
path: '/Users/aamer/.codex/generated_images/mockup.png',
|
||||
source: 'codex-generated',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits generated artifact requests from Codex saved-to output', () => {
|
||||
const session = new Session({ id: 'session-generated-artifact-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const requested: Array<{ path: string; source?: string }> = [];
|
||||
@@ -88,6 +111,20 @@ describe('attachment magic links', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not emit codex-generated requests from non-codex session modes', () => {
|
||||
for (const mode of ['claude', 'shell'] as const) {
|
||||
const session = new Session({ id: `session-generated-artifact-${mode}`, workingDir: '/tmp', mode });
|
||||
const requested: Array<{ path: string }> = [];
|
||||
session.on('attachmentRequested', (event: { path: string }) => requested.push(event));
|
||||
|
||||
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/output.png'
|
||||
);
|
||||
|
||||
expect(requested).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports generated image attachment extensions beyond png', () => {
|
||||
expect(isSupportedAttachmentExtension('jpg')).toBe(true);
|
||||
expect(isSupportedAttachmentExtension('jpeg')).toBe(true);
|
||||
|
||||
@@ -71,6 +71,22 @@ describe('document-thumbnailer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through generated image formats with per-extension content types', async () => {
|
||||
const expectations: Array<[string, string]> = [
|
||||
['jpg', 'image/jpeg'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['gif', 'image/gif'],
|
||||
['webp', 'image/webp'],
|
||||
['png', 'image/png'],
|
||||
];
|
||||
|
||||
for (const [ext, contentType] of expectations) {
|
||||
const result = await generateFirstPageThumbnail(`/tmp/mockup.${ext}`, ext);
|
||||
expect(result).toEqual({ content: Buffer.from('large thumbnail'), contentType });
|
||||
}
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
||||
mockedMkdtemp.mockImplementation(async (prefix) =>
|
||||
String(prefix).includes('codeman-document-preview-cache')
|
||||
|
||||
@@ -1,15 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isAllowedGeneratedArtifactPath } from '../src/generated-artifact-attachments.js';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
isAllowedGeneratedArtifactPath,
|
||||
registerGeneratedArtifactAttachment,
|
||||
} from '../src/generated-artifact-attachments.js';
|
||||
import { attachmentRegistry } from '../src/attachment-registry.js';
|
||||
|
||||
describe('generated artifact attachments', () => {
|
||||
it('allows workspace artifacts and known Codex generated image directories', () => {
|
||||
it('allows workspace artifacts and home-anchored Codex generated image directories', () => {
|
||||
const home = homedir();
|
||||
expect(isAllowedGeneratedArtifactPath('/repo/out/mockup.png', '/repo')).toBe(true);
|
||||
expect(isAllowedGeneratedArtifactPath('/Users/aamer/.codex-personal/generated_images/mockup.png', '/repo')).toBe(
|
||||
expect(
|
||||
isAllowedGeneratedArtifactPath(join(home, '.codex-personal', 'generated_images', 'mockup.png'), '/repo')
|
||||
).toBe(true);
|
||||
expect(isAllowedGeneratedArtifactPath(join(home, '.codex', 'generated_artifacts', 'report.pdf'), '/repo')).toBe(
|
||||
true
|
||||
);
|
||||
expect(isAllowedGeneratedArtifactPath('/etc/secret.png', '/repo')).toBe(false);
|
||||
expect(
|
||||
isAllowedGeneratedArtifactPath('/Users/aamer/.codex-personal/generated_images/../../.ssh/id_rsa.png', '/repo')
|
||||
isAllowedGeneratedArtifactPath(
|
||||
join(home, '.codex-personal', 'generated_images', '..', '..', '.ssh', 'id_rsa.png'),
|
||||
'/repo'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects .codex marker directories that are not anchored at the user home', () => {
|
||||
expect(isAllowedGeneratedArtifactPath('/var/tmp/staging/.codex/generated_images/leak.png', '/repo')).toBe(false);
|
||||
expect(isAllowedGeneratedArtifactPath('/var/tmp/.codex-personal/generated_artifacts/leak.md', '/repo')).toBe(false);
|
||||
});
|
||||
|
||||
describe('symlink resolution', () => {
|
||||
let workspaceDir: string | undefined;
|
||||
let outsideDir: string | undefined;
|
||||
const sessionId = 'generated-artifact-symlink-test';
|
||||
|
||||
afterEach(async () => {
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
for (const dir of [workspaceDir, outsideDir]) {
|
||||
if (dir) await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
workspaceDir = undefined;
|
||||
outsideDir = undefined;
|
||||
});
|
||||
|
||||
it('confines on the resolved path: a workspace symlink to an outside file is rejected', async () => {
|
||||
// realpath so a symlinked tmpdir (e.g. macOS /var -> /private/var) can't skew containment checks
|
||||
workspaceDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-ws-')));
|
||||
outsideDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-out-')));
|
||||
const outsideFile = join(outsideDir, 'private-notes.md');
|
||||
await fs.writeFile(outsideFile, 'secret');
|
||||
const linkPath = join(workspaceDir, 'x.md');
|
||||
await fs.symlink(outsideFile, linkPath);
|
||||
|
||||
await expect(
|
||||
registerGeneratedArtifactAttachment({ sessionId, filePath: linkPath, sessionWorkingDir: workspaceDir })
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('registers a real workspace file', async () => {
|
||||
workspaceDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-ws-')));
|
||||
const filePath = join(workspaceDir, 'mockup.png');
|
||||
await fs.writeFile(filePath, 'png-bytes');
|
||||
|
||||
const event = await registerGeneratedArtifactAttachment({
|
||||
sessionId,
|
||||
filePath,
|
||||
sessionWorkingDir: workspaceDir,
|
||||
});
|
||||
|
||||
expect(event.fileName).toBe('mockup.png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
||||
|
||||
describe('session listener wiring', () => {
|
||||
it('forwards the attachment request source through registerAttachment', async () => {
|
||||
const session = new Session({ id: 'wiring-attach-source-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const registerAttachment = vi.fn(async () => undefined);
|
||||
const deps = { registerAttachment } as unknown as Parameters<typeof createSessionListeners>[1];
|
||||
|
||||
const refs = createSessionListeners(session, deps);
|
||||
refs.attachmentRequested({ path: '/tmp/mockup.png', source: 'codex-generated' });
|
||||
refs.attachmentRequested({ path: '/tmp/report.pdf', source: 'external' });
|
||||
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'wiring-attach-source-test',
|
||||
'/tmp/mockup.png',
|
||||
'codex-generated'
|
||||
);
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user