From f8aa93969b1d39df1ae04c1567a7093291b89792 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Wed, 1 Jul 2026 09:57:47 +0000 Subject: [PATCH 1/3] fix: COD-152 surface Codex generated artifacts --- src/attachment-magic.ts | 47 ++++++++ src/attachment-registry.ts | 15 ++- src/generated-artifact-attachments.ts | 112 ++++++++++++++++++++ src/session.ts | 24 +++-- src/web/server.ts | 23 +++- src/web/session-listener-wiring.ts | 8 +- test/attachment-magic.test.ts | 41 ++++++- test/generated-artifact-attachments.test.ts | 55 ++++++++++ 8 files changed, 304 insertions(+), 21 deletions(-) create mode 100644 src/generated-artifact-attachments.ts create mode 100644 test/generated-artifact-attachments.test.ts diff --git a/src/attachment-magic.ts b/src/attachment-magic.ts index f4d6d763..e0e1c142 100644 --- a/src/attachment-magic.ts +++ b/src/attachment-magic.ts @@ -3,11 +3,36 @@ */ import { isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { isSupportedAttachmentExtension } from './attachment-registry.js'; const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g; +const CODEX_SAVED_FILE_RE = /\bSaved to:\s*(file:\/\/[^\s<>"']+)/gi; + +export interface TerminalAttachmentRequest { + path: string; + source: 'external' | 'codex-generated'; +} export function parseAttachmentMagicLinks(data: string): string[] { + return parseMagicAttachmentRequests(data).map((request) => request.path); +} + +export function parseTerminalAttachmentRequests(data: string): TerminalAttachmentRequest[] { + const results: TerminalAttachmentRequest[] = []; + const seen = new Set(); + + for (const request of [...parseMagicAttachmentRequests(data), ...parseCodexGeneratedArtifactRequests(data)]) { + const key = `${request.source}:${request.path}`; + if (seen.has(key)) continue; + seen.add(key); + results.push(request); + } + + return results; +} + +function parseMagicAttachmentRequests(data: string): TerminalAttachmentRequest[] { const results: string[] = []; const seen = new Set(); @@ -27,6 +52,28 @@ export function parseAttachmentMagicLinks(data: string): string[] { } } + return results.map((path) => ({ path, source: 'external' })); +} + +function parseCodexGeneratedArtifactRequests(data: string): TerminalAttachmentRequest[] { + const results: TerminalAttachmentRequest[] = []; + const seen = new Set(); + + for (const match of data.matchAll(CODEX_SAVED_FILE_RE)) { + const rawUrl = trimTrailingPunctuation(match[1] || ''); + try { + const filePath = fileURLToPath(rawUrl); + if (!isAbsolute(filePath)) continue; + const extension = filePath.split('.').pop()?.toLowerCase() || ''; + if (!isSupportedAttachmentExtension(extension)) continue; + if (seen.has(filePath)) continue; + seen.add(filePath); + results.push({ path: filePath, source: 'codex-generated' }); + } catch { + // Ignore malformed terminal text. Generated-artifact links are advisory. + } + } + return results; } diff --git a/src/attachment-registry.ts b/src/attachment-registry.ts index 3bfd4b40..cfd6fb85 100644 --- a/src/attachment-registry.ts +++ b/src/attachment-registry.ts @@ -14,7 +14,18 @@ import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/att import { validateSessionFilePath } from './web/route-helpers.js'; import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js'; -const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']); +const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'pdf', + 'docx', + 'pptx', + 'md', + 'txt', +]); export type AttachmentSource = 'detected' | 'external'; @@ -96,7 +107,7 @@ export function isSupportedAttachmentExtension(extension: string): boolean { export function getAttachmentType(extension: string): AttachmentDetectedType { const normalized = extension.toLowerCase().replace(/^\./, ''); - if (normalized === 'png') return 'image'; + if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(normalized)) return 'image'; if (normalized === 'pdf') return 'pdf'; if (normalized === 'pptx') return 'presentation'; if (normalized === 'md') return 'markdown'; diff --git a/src/generated-artifact-attachments.ts b/src/generated-artifact-attachments.ts new file mode 100644 index 00000000..9e050902 --- /dev/null +++ b/src/generated-artifact-attachments.ts @@ -0,0 +1,112 @@ +/** + * @fileoverview Codex generated-artifact attachment registration. + * + * Codex image generation prints paths such as `Saved to: file://...`. For local + * sessions these paths can be registered directly when they are safe. For remote + * SSH sessions the path exists on the remote host, so Codeman first copies the + * bytes into its instance data directory and then serves that cached copy through + * the existing attachment registry. + */ + +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import { basename, join, posix as posixPath } from 'node:path'; +import { promisify } from 'node:util'; +import fs from 'node:fs/promises'; +import { dataPath } from './config/instance.js'; +import { registerExternalAttachment, type AttachmentRegistrationResult } from './attachment-registry.js'; +import { buildSshConnectionArgv, remoteSshTarget, shellescape } from './remote-hosts.js'; +import type { SessionRemote } from './types/session.js'; + +const execFileAsync = promisify(execFile); + +const MAX_GENERATED_ARTIFACT_BYTES = 50 * 1024 * 1024; +const REMOTE_FETCH_TIMEOUT_MS = 30_000; + +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; + sessionWorkingDir: string; + remote?: SessionRemote; +} + +export async function registerGeneratedArtifactAttachment( + options: GeneratedArtifactRegistrationOptions +): Promise { + if (options.remote) { + if (!isAllowedGeneratedArtifactPath(options.filePath, options.remote.remotePath)) { + throw new Error('Generated artifact path is outside allowed remote locations'); + } + const localPath = await materializeRemoteGeneratedArtifact(options.sessionId, options.remote, options.filePath); + return registerExternalAttachment(options.sessionId, localPath, { sessionWorkingDir: options.sessionWorkingDir }); + } + + const forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(options.filePath, options.sessionWorkingDir); + return registerExternalAttachment(options.sessionId, options.filePath, { + sessionWorkingDir: options.sessionWorkingDir, + forceWorkspaceConfinement, + }); +} + +export function isAllowedGeneratedArtifactPath(filePath: string, workingDir: string): boolean { + const normalizedPath = posixPath.normalize(filePath); + if (isPathInside(normalizedPath, workingDir)) return true; + return CODEX_GENERATED_DIR_MARKERS.some((marker) => normalizedPath.includes(marker)); +} + +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}/`; +} + +async function materializeRemoteGeneratedArtifact( + sessionId: string, + remote: SessionRemote, + remotePath: string +): Promise { + const fileName = sanitizeCacheFileName(basename(remotePath)); + const digest = createHash('sha256') + .update(`${remote.username}@${remote.host}:${remote.port ?? 22}:${remotePath}`) + .digest('hex') + .slice(0, 16); + const cacheDir = dataPath('generated-artifacts', sessionId); + await mkdir(cacheDir, { recursive: true }); + const localPath = join(cacheDir, `${digest}-${fileName}`); + + const args = buildRemoteGeneratedArtifactFetchArgs(remote, remotePath); + const { stdout } = (await execFileAsync('ssh', args, { + encoding: 'buffer', + timeout: REMOTE_FETCH_TIMEOUT_MS, + maxBuffer: MAX_GENERATED_ARTIFACT_BYTES, + })) as { stdout: Buffer }; + + await fs.writeFile(localPath, stdout); + return localPath; +} + +export function buildRemoteGeneratedArtifactFetchArgs(remote: SessionRemote, remotePath: string): string[] { + return [ + ...buildSshConnectionArgv(remote), + '-o', + 'ConnectTimeout=10', + remoteSshTarget(remote), + `cat -- ${shellescape(remotePath)}`, + ]; +} + +function sanitizeCacheFileName(fileName: string): string { + return fileName.replace(/[^A-Za-z0-9._-]/g, '_') || 'artifact'; +} diff --git a/src/session.ts b/src/session.ts index daeb1667..7ed267c7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -82,7 +82,7 @@ import { import { SessionAutoOps } from './session-auto-ops.js'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; import { SessionTaskCache } from './session-task-cache.js'; -import { parseAttachmentMagicLinks } from './attachment-magic.js'; +import { parseTerminalAttachmentRequests } from './attachment-magic.js'; import { sanitizeAttachmentHistory, upsertAttachmentHistory as upsertAttachmentHistoryList, @@ -1230,18 +1230,24 @@ export class Session extends EventEmitter { .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, ''); } - // Scan terminal output for `codeman://attach?path=...` magic links and emit - // an attachmentRequested event for each newly-seen absolute path. The web - // server turns these into registered attachment cards. - const attachmentPaths = parseAttachmentMagicLinks(data); - for (const attachmentPath of attachmentPaths) { - if (this._attachmentMagicSeen.has(attachmentPath)) continue; - this._attachmentMagicSeen.add(attachmentPath); + // 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); + for (const request of attachmentRequests) { + const seenKey = `${request.source}:${request.path}`; + if (this._attachmentMagicSeen.has(seenKey)) continue; + this._attachmentMagicSeen.add(seenKey); if (this._attachmentMagicSeen.size > 200) { const oldest = this._attachmentMagicSeen.values().next().value; if (oldest) this._attachmentMagicSeen.delete(oldest); } - this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() }); + this.emit('attachmentRequested', { + sessionId: this.id, + path: request.path, + source: request.source, + timestamp: Date.now(), + }); } // BufferAccumulator handles auto-trimming when max size exceeded diff --git a/src/web/server.ts b/src/web/server.ts index 428630cc..47d55d62 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -62,6 +62,7 @@ import { import { imageWatcher } from '../image-watcher.js'; import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js'; import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js'; +import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js'; import { buildDetectedAttachmentHistoryItem, buildExternalAttachmentHistoryItem, @@ -1345,13 +1346,25 @@ export class WebServer extends EventEmitter { * directly inside a managed session). Registration also enforces the COD-53 * blocklist as defense-in-depth. */ - private async registerAttachment(sessionId: string, filePath: string): Promise { + private async registerAttachment( + sessionId: string, + filePath: string, + source: 'external' | 'codex-generated' = 'external' + ): Promise { const session = this.sessions.get(sessionId); if (!session) return; - const event = await registerExternalAttachment(sessionId, filePath, { - sessionWorkingDir: session.workingDir, - forceWorkspaceConfinement: true, - }); + const event = + source === 'codex-generated' + ? await registerGeneratedArtifactAttachment({ + sessionId, + filePath, + sessionWorkingDir: session.workingDir, + remote: session.remote, + }) + : await registerExternalAttachment(sessionId, filePath, { + sessionWorkingDir: session.workingDir, + forceWorkspaceConfinement: true, + }); const record = attachmentRegistry.get(sessionId, event.attachmentId); if (record) { session.upsertAttachmentHistory( diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 5a0f9a4f..0226cafd 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -58,7 +58,7 @@ export interface SessionListenerRefs { bashToolStart: (tool: ActiveBashTool) => void; bashToolEnd: (tool: ActiveBashTool) => void; bashToolsUpdate: (tools: ActiveBashTool[]) => void; - attachmentRequested: (event: { path: string }) => 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): Promise; + registerAttachment(sessionId: string, filePath: string, source?: 'external' | 'codex-generated'): Promise; } /** @@ -359,8 +359,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe }, /** Registers an explicit attachment card requested by terminal magic text. */ - attachmentRequested: (event: { path: string }) => { - deps.registerAttachment(session.id, event.path).catch((err) => { + 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); }); }, diff --git a/test/attachment-magic.test.ts b/test/attachment-magic.test.ts index 0acbae2d..70713b02 100644 --- a/test/attachment-magic.test.ts +++ b/test/attachment-magic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Session } from '../src/session.js'; -import { parseAttachmentMagicLinks } from '../src/attachment-magic.js'; +import { parseAttachmentMagicLinks, parseTerminalAttachmentRequests } from '../src/attachment-magic.js'; +import { isSupportedAttachmentExtension } from '../src/attachment-registry.js'; describe('attachment magic links', () => { it('extracts absolute paths from codeman attach magic URLs', () => { @@ -54,4 +55,42 @@ describe('attachment magic links', () => { expect(requested).toEqual(['/tmp/deck.pptx']); }); + + 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' + ); + + expect(requests).toEqual([ + { + path: '/Users/aamer/.codex-personal/generated_images/mockup one.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 }> = []; + session.on('attachmentRequested', (event: { path: string; source?: 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([ + { + sessionId: 'session-generated-artifact-test', + path: '/Users/aamer/.codex-personal/generated_images/output.png', + source: 'codex-generated', + timestamp: expect.any(Number), + }, + ]); + }); + + it('supports generated image attachment extensions beyond png', () => { + expect(isSupportedAttachmentExtension('jpg')).toBe(true); + expect(isSupportedAttachmentExtension('jpeg')).toBe(true); + expect(isSupportedAttachmentExtension('webp')).toBe(true); + }); }); diff --git a/test/generated-artifact-attachments.test.ts b/test/generated-artifact-attachments.test.ts new file mode 100644 index 00000000..353ee538 --- /dev/null +++ b/test/generated-artifact-attachments.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + buildRemoteGeneratedArtifactFetchArgs, + isAllowedGeneratedArtifactPath, +} from '../src/generated-artifact-attachments.js'; +import type { SessionRemote } from '../src/types/session.js'; + +describe('generated artifact attachments', () => { + it('allows workspace artifacts and known Codex generated image directories', () => { + expect(isAllowedGeneratedArtifactPath('/repo/out/mockup.png', '/repo')).toBe(true); + expect(isAllowedGeneratedArtifactPath('/Users/aamer/.codex-personal/generated_images/mockup.png', '/repo')).toBe( + true + ); + expect(isAllowedGeneratedArtifactPath('/etc/secret.png', '/repo')).toBe(false); + expect( + isAllowedGeneratedArtifactPath('/Users/aamer/.codex-personal/generated_images/../../.ssh/id_rsa.png', '/repo') + ).toBe(false); + }); + + it('builds remote fetch argv from the session SSH configuration', () => { + const remote: SessionRemote = { + hostId: 'mac-mini', + label: 'mac-mini', + host: '192.168.1.20', + username: 'aamer', + port: 2222, + remotePath: '/Users/aamer/projects/app', + identityFile: '~/.ssh/remote_ed25519', + socksProxy: '127.0.0.1:1080', + jumpHost: 'jump.example.com', + extraSshOptions: ['StrictHostKeyChecking=no'], + }; + + expect( + buildRemoteGeneratedArtifactFetchArgs(remote, "/Users/aamer/.codex-personal/generated_images/a'b.png") + ).toEqual([ + '-o', + 'BatchMode=yes', + '-p', + '2222', + '-i', + expect.stringMatching(/remote_ed25519$/), + '-J', + 'jump.example.com', + '-o', + 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p', + '-o', + 'StrictHostKeyChecking=no', + '-o', + 'ConnectTimeout=10', + 'aamer@192.168.1.20', + "cat -- '/Users/aamer/.codex-personal/generated_images/a'\\''b.png'", + ]); + }); +}); From 978ca57343ebd8f18882b5a5e2d15fcb5c576bd9 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Wed, 1 Jul 2026 10:40:09 +0000 Subject: [PATCH 2/3] fix: COD-152 preserve generated artifact filenames --- src/generated-artifact-attachments.ts | 71 ++------------------- src/web/server.ts | 1 - test/generated-artifact-attachments.test.ts | 42 +----------- 3 files changed, 5 insertions(+), 109 deletions(-) diff --git a/src/generated-artifact-attachments.ts b/src/generated-artifact-attachments.ts index 9e050902..3c8d2dde 100644 --- a/src/generated-artifact-attachments.ts +++ b/src/generated-artifact-attachments.ts @@ -1,28 +1,13 @@ /** * @fileoverview Codex generated-artifact attachment registration. * - * Codex image generation prints paths such as `Saved to: file://...`. For local - * sessions these paths can be registered directly when they are safe. For remote - * SSH sessions the path exists on the remote host, so Codeman first copies the - * bytes into its instance data directory and then serves that cached copy through - * the existing attachment registry. + * 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). */ -import { createHash } from 'node:crypto'; -import { execFile } from 'node:child_process'; -import { mkdir } from 'node:fs/promises'; -import { basename, join, posix as posixPath } from 'node:path'; -import { promisify } from 'node:util'; -import fs from 'node:fs/promises'; -import { dataPath } from './config/instance.js'; +import { posix as posixPath } from 'node:path'; import { registerExternalAttachment, type AttachmentRegistrationResult } from './attachment-registry.js'; -import { buildSshConnectionArgv, remoteSshTarget, shellescape } from './remote-hosts.js'; -import type { SessionRemote } from './types/session.js'; - -const execFileAsync = promisify(execFile); - -const MAX_GENERATED_ARTIFACT_BYTES = 50 * 1024 * 1024; -const REMOTE_FETCH_TIMEOUT_MS = 30_000; const CODEX_GENERATED_DIR_MARKERS = [ '/.codex-personal/generated_images/', @@ -35,20 +20,11 @@ export interface GeneratedArtifactRegistrationOptions { sessionId: string; filePath: string; sessionWorkingDir: string; - remote?: SessionRemote; } export async function registerGeneratedArtifactAttachment( options: GeneratedArtifactRegistrationOptions ): Promise { - if (options.remote) { - if (!isAllowedGeneratedArtifactPath(options.filePath, options.remote.remotePath)) { - throw new Error('Generated artifact path is outside allowed remote locations'); - } - const localPath = await materializeRemoteGeneratedArtifact(options.sessionId, options.remote, options.filePath); - return registerExternalAttachment(options.sessionId, localPath, { sessionWorkingDir: options.sessionWorkingDir }); - } - const forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(options.filePath, options.sessionWorkingDir); return registerExternalAttachment(options.sessionId, options.filePath, { sessionWorkingDir: options.sessionWorkingDir, @@ -71,42 +47,3 @@ function isPathInside(filePath: string, rootPath: string): boolean { function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/`; } - -async function materializeRemoteGeneratedArtifact( - sessionId: string, - remote: SessionRemote, - remotePath: string -): Promise { - const fileName = sanitizeCacheFileName(basename(remotePath)); - const digest = createHash('sha256') - .update(`${remote.username}@${remote.host}:${remote.port ?? 22}:${remotePath}`) - .digest('hex') - .slice(0, 16); - const cacheDir = dataPath('generated-artifacts', sessionId); - await mkdir(cacheDir, { recursive: true }); - const localPath = join(cacheDir, `${digest}-${fileName}`); - - const args = buildRemoteGeneratedArtifactFetchArgs(remote, remotePath); - const { stdout } = (await execFileAsync('ssh', args, { - encoding: 'buffer', - timeout: REMOTE_FETCH_TIMEOUT_MS, - maxBuffer: MAX_GENERATED_ARTIFACT_BYTES, - })) as { stdout: Buffer }; - - await fs.writeFile(localPath, stdout); - return localPath; -} - -export function buildRemoteGeneratedArtifactFetchArgs(remote: SessionRemote, remotePath: string): string[] { - return [ - ...buildSshConnectionArgv(remote), - '-o', - 'ConnectTimeout=10', - remoteSshTarget(remote), - `cat -- ${shellescape(remotePath)}`, - ]; -} - -function sanitizeCacheFileName(fileName: string): string { - return fileName.replace(/[^A-Za-z0-9._-]/g, '_') || 'artifact'; -} diff --git a/src/web/server.ts b/src/web/server.ts index 47d55d62..feec0d05 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1359,7 +1359,6 @@ export class WebServer extends EventEmitter { sessionId, filePath, sessionWorkingDir: session.workingDir, - remote: session.remote, }) : await registerExternalAttachment(sessionId, filePath, { sessionWorkingDir: session.workingDir, diff --git a/test/generated-artifact-attachments.test.ts b/test/generated-artifact-attachments.test.ts index 353ee538..6b7b7070 100644 --- a/test/generated-artifact-attachments.test.ts +++ b/test/generated-artifact-attachments.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - buildRemoteGeneratedArtifactFetchArgs, - isAllowedGeneratedArtifactPath, -} from '../src/generated-artifact-attachments.js'; -import type { SessionRemote } from '../src/types/session.js'; +import { isAllowedGeneratedArtifactPath } from '../src/generated-artifact-attachments.js'; describe('generated artifact attachments', () => { it('allows workspace artifacts and known Codex generated image directories', () => { @@ -16,40 +12,4 @@ describe('generated artifact attachments', () => { isAllowedGeneratedArtifactPath('/Users/aamer/.codex-personal/generated_images/../../.ssh/id_rsa.png', '/repo') ).toBe(false); }); - - it('builds remote fetch argv from the session SSH configuration', () => { - const remote: SessionRemote = { - hostId: 'mac-mini', - label: 'mac-mini', - host: '192.168.1.20', - username: 'aamer', - port: 2222, - remotePath: '/Users/aamer/projects/app', - identityFile: '~/.ssh/remote_ed25519', - socksProxy: '127.0.0.1:1080', - jumpHost: 'jump.example.com', - extraSshOptions: ['StrictHostKeyChecking=no'], - }; - - expect( - buildRemoteGeneratedArtifactFetchArgs(remote, "/Users/aamer/.codex-personal/generated_images/a'b.png") - ).toEqual([ - '-o', - 'BatchMode=yes', - '-p', - '2222', - '-i', - expect.stringMatching(/remote_ed25519$/), - '-J', - 'jump.example.com', - '-o', - 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p', - '-o', - 'StrictHostKeyChecking=no', - '-o', - 'ConnectTimeout=10', - 'aamer@192.168.1.20', - "cat -- '/Users/aamer/.codex-personal/generated_images/a'\\''b.png'", - ]); - }); }); From 13c877f9385dbafef5f81ff2e46a66b050cf91ca Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 12 Jul 2026 17:50:21 +0200 Subject: [PATCH 3/3] fix(review): harden Codex generated-artifact attachment pipeline (PR #150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/attachment-magic.ts | 25 ++++++- src/document-thumbnailer.ts | 16 ++++- src/generated-artifact-attachments.ts | 61 +++++++++++------ src/session.ts | 8 ++- src/web/server.ts | 13 ++-- src/web/session-listener-wiring.ts | 6 +- test/attachment-magic.test.ts | 39 ++++++++++- test/document-thumbnailer.test.ts | 16 +++++ test/generated-artifact-attachments.test.ts | 73 +++++++++++++++++++-- test/session-listener-wiring.test.ts | 23 +++++++ 10 files changed, 238 insertions(+), 42 deletions(-) create mode 100644 test/session-listener-wiring.test.ts diff --git a/src/attachment-magic.ts b/src/attachment-magic.ts index e0e1c142..086700c7 100644 --- a/src/attachment-magic.ts +++ b/src/attachment-magic.ts @@ -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(); + 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(); - 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); diff --git a/src/document-thumbnailer.ts b/src/document-thumbnailer.ts index 8972bebb..9da8d027 100644 --- a/src/document-thumbnailer.ts +++ b/src/document-thumbnailer.ts @@ -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 = { + 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 { @@ -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') { diff --git a/src/generated-artifact-attachments.ts b/src/generated-artifact-attachments.ts index 3c8d2dde..afe0c52e 100644 --- a/src/generated-artifact-attachments.ts +++ b/src/generated-artifact-attachments.ts @@ -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 { - 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); } diff --git a/src/session.ts b/src/session.ts index 7ed267c7..5532f955 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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; diff --git a/src/web/server.ts b/src/web/server.ts index feec0d05..f3884b57 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 { const session = this.sessions.get(sessionId); if (!session) return; diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 0226cafd..ba53626e 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -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; + registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise; } /** @@ -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); }); diff --git a/test/attachment-magic.test.ts b/test/attachment-magic.test.ts index 70713b02..2f734de6 100644 --- a/test/attachment-magic.test.ts +++ b/test/attachment-magic.test.ts @@ -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); diff --git a/test/document-thumbnailer.test.ts b/test/document-thumbnailer.test.ts index 1652afaf..785951e6 100644 --- a/test/document-thumbnailer.test.ts +++ b/test/document-thumbnailer.test.ts @@ -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') diff --git a/test/generated-artifact-attachments.test.ts b/test/generated-artifact-attachments.test.ts index 6b7b7070..938cb806 100644 --- a/test/generated-artifact-attachments.test.ts +++ b/test/generated-artifact-attachments.test.ts @@ -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'); + }); + }); }); diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts new file mode 100644 index 00000000..74ea312c --- /dev/null +++ b/test/session-listener-wiring.test.ts @@ -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[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'); + }); +});