feat(daemon): let Codex image projects use built-in imagegen (#622)

* feat(daemon): let Codex image projects avoid API-key setup

Codex has a built-in image generation path available inside the agent runtime, while the generic media dispatcher still routes gpt-image models through the daemon OpenAI provider. Pass the active agent id into prompt composition so Codex-only gpt-image projects can use built-in imagegen first without changing non-Codex media behavior.

Constraint: Existing media contract remains the default path for non-Codex agents and explicit provider fallback
Rejected: Add a nested daemon Codex media provider | heavier auth, streaming, timeout, cancellation, and output parsing surface for this parity fix
Confidence: high
Scope-risk: narrow
Directive: Keep this override after the media contract so it can intentionally supersede dispatcher-only wording for Codex gpt-image projects
Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/system-prompt-template.test.ts
Tested: pnpm --filter @open-design/daemon typecheck
Tested: pnpm guard
Tested: pnpm typecheck
Not-tested: Live Codex image generation inside the Open Design UI

* fix(daemon): harden Codex imagegen prompt routing

PR review found the Codex override could be superseded by the web-supplied media contract, trusted unvalidated image model metadata, and assumed generated image paths outside the workspace were readable.

This keeps the override daemon-owned, appends it last in the live prompt, validates against registered gpt-image model IDs, allowlists only Codex's generated_images folder, and tightens copy-failure instructions.

Constraint: The web contracts composer still emits the generic media contract without agent identity.

Rejected: Mirror Codex-specific prompt logic into contracts/web | duplicates daemon model registry and still leaves final ordering fragile.

Confidence: high

Scope-risk: narrow

Directive: Keep Codex imagegen override appended after client systemPrompt so it remains the final media instruction for Codex gpt-image projects.

Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/system-prompt-template.test.ts tests/agents.test.ts tests/chat-route.test.ts

Tested: pnpm --filter @open-design/daemon typecheck

Tested: pnpm guard

Tested: pnpm typecheck

Not-tested: Live Codex image generation inside the Open Design UI

* fix(daemon): keep Codex add-dir writable scope narrow

PR review found Codex --add-dir grants writable workspace access, so passing skill, design-system, and linked reference directories through the same chat allowlist broke their documented read-only boundary.

This routes chat extra directories by active agent: Codex receives only the validated generated_images output directory needed for built-in imagegen, while non-Codex adapters keep the existing resource and linked-directory read access behavior.

Constraint: Codex CLI treats --add-dir as writable sandbox expansion.

Constraint: The daemon still stages active skill files into the project cwd as Codex's read-safe path.

Rejected: Keep one shared extraAllowedDirs list for all agents | grants Codex write access to read-only resources.

Confidence: high

Scope-risk: narrow

Directive: Do not add read-only resource/reference directories to Codex --add-dir unless Codex gains a read-only allowlist flag.

Tested: git diff --check -- apps/daemon/src/server.ts apps/daemon/tests/chat-route.test.ts

Tested: pnpm --filter @open-design/daemon exec vitest run tests/chat-route.test.ts

Tested: pnpm --filter @open-design/daemon typecheck

Tested: pnpm guard

Tested: pnpm typecheck

Not-tested: Live Codex image generation inside the Open Design UI

* fix(daemon): validate Codex imagegen add-dir grants

PR review found the generated_images grant still trusted symlinked paths and rendered the Codex override before proving the sandbox grant would be present.

This validates the generated_images directory before prompt assembly, rejects final-component symlinks and protected-root canonical escapes, passes Codex the canonical grant path, and only appends the Codex imagegen override when that same path is in extraAllowedDirs.

Constraint: Codex --add-dir grants writable workspace access, so path aliases into read-only resource roots must be rejected.

Rejected: Keep returning the nominal CODEX_HOME path after validation | leaves Codex operating through a symlink alias instead of the audited grant target.

Confidence: high

Scope-risk: narrow

Directive: Keep Codex imagegen prompt rendering downstream of generated_images validation and grant resolution.

Tested: git diff --check -- apps/daemon/src/server.ts apps/daemon/tests/chat-route.test.ts

Tested: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/chat-route.test.ts

Tested: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/agents.test.ts tests/chat-route.test.ts

Tested: pnpm --filter @open-design/daemon typecheck

Tested: pnpm guard

Tested: pnpm typecheck

Not-tested: Live Codex image generation inside the Open Design UI
This commit is contained in:
Deheng Huang 2026-05-06 18:28:16 +08:00 committed by GitHub
parent c8127b78fd
commit 09b78c2f9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 890 additions and 36 deletions

View file

@ -764,7 +764,7 @@ Auto-detected from `PATH` on daemon boot. No config required. Streaming dispatch
| Agent | Bin | Stream format | Argv shape (composed prompt path) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (typed events) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` parser | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]` (prompt on stdin) |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` parser | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--add-dir …] [--model …] [-c model_reasoning_effort=…]` (prompt on stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + `gemini` parser | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]` (prompt on stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + `opencode` parser | `opencode run --format json --dangerously-skip-permissions [--model …] -` (prompt on stdin) |

View file

@ -44,10 +44,11 @@ const agentCapabilities = new Map();
// user's local CLI config wins.
//
// `extraAllowedDirs` is a list of absolute directories the agent must be
// permitted to read files from (skill seeds, design-system specs) that live
// outside the project cwd. Currently only Claude Code wires this through
// (`--add-dir`); other agents either inherit broader access or run with cwd
// boundaries we can't widen via flags.
// permitted to read files from (skill seeds, design-system specs, narrowly
// scoped tool output dirs) that live outside the project cwd. Agents with a
// documented access-widening flag wire this through (`--add-dir`); the rest
// either inherit broader access or run with cwd boundaries we can't widen via
// flags.
//
// `streamFormat` hints to the daemon how to interpret stdout:
// - 'claude-stream-json' : line-delimited JSON emitted by Claude Code's
@ -214,7 +215,7 @@ export const AGENT_DEFS = [
buildArgs: (
_prompt,
_imagePaths,
_extra,
extraAllowedDirs = [],
options = {},
runtimeContext = {},
) => {
@ -233,6 +234,12 @@ export const AGENT_DEFS = [
if (runtimeContext.cwd) {
args.push('-C', runtimeContext.cwd);
}
const dirs = (extraAllowedDirs || []).filter(
(d) => typeof d === 'string' && d.length > 0,
);
for (const d of dirs) {
args.push('--add-dir', d);
}
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}

View file

@ -33,6 +33,7 @@ import { OFFICIAL_DESIGNER_PROMPT } from './official-system.js';
import { DISCOVERY_AND_PHILOSOPHY } from './discovery.js';
import { DECK_FRAMEWORK_DIRECTIVE } from './deck-framework.js';
import { MEDIA_GENERATION_CONTRACT } from './media-contract.js';
import { IMAGE_MODELS } from '../media-models.js';
type ProjectMetadata = {
kind?: string;
@ -76,6 +77,8 @@ type ProjectTemplate = { name: string; description?: string | null; files: Array
export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT;
export interface ComposeInput {
agentId?: string | null | undefined;
includeCodexImagegenOverride?: boolean | undefined;
skillBody?: string | undefined;
skillName?: string | undefined;
skillMode?:
@ -108,6 +111,8 @@ export interface ComposeInput {
}
export function composeSystemPrompt({
agentId,
includeCodexImagegenOverride = true,
skillBody,
skillName,
skillMode,
@ -188,9 +193,100 @@ export function composeSystemPrompt({
parts.push(MEDIA_GENERATION_CONTRACT);
}
if (includeCodexImagegenOverride) {
const codexImagegenOverride = renderCodexImagegenOverride(
agentId,
metadata,
);
if (codexImagegenOverride) {
parts.push(codexImagegenOverride);
}
}
return parts.join('');
}
const CODEX_IMAGEGEN_MODEL_IDS = new Set(
IMAGE_MODELS.filter(
(model) =>
model?.provider === 'openai' &&
typeof model?.id === 'string' &&
model.id.startsWith('gpt-image-'),
).map((model) => model.id),
);
export function resolveCodexImagegenModelId(
metadata: ProjectMetadata | undefined,
): string {
const imageModel =
typeof metadata?.imageModel === 'string' ? metadata.imageModel.trim() : '';
return CODEX_IMAGEGEN_MODEL_IDS.has(imageModel) ? imageModel : '';
}
export function shouldRenderCodexImagegenOverride(
agentId: string | null | undefined,
metadata: ProjectMetadata | undefined,
): boolean {
const normalizedAgentId =
typeof agentId === 'string' ? agentId.trim().toLowerCase() : '';
return (
normalizedAgentId === 'codex' &&
metadata?.kind === 'image' &&
resolveCodexImagegenModelId(metadata).length > 0
);
}
export function renderCodexImagegenOverride(
agentId: string | null | undefined,
metadata: ProjectMetadata | undefined,
): string {
if (!shouldRenderCodexImagegenOverride(agentId, metadata)) {
return '';
}
const imageModel = resolveCodexImagegenModelId(metadata);
return `
---
## Codex built-in imagegen override (load-bearing Codex only)
The active agent is Codex and this image project selected \`${imageModel}\`.
For this specific case, use Codex's built-in image generation capability
instead of \`"$OD_NODE_BIN" "$OD_BIN" media generate\` for the first generation
attempt. This is an intentional exception to the media generation contract and
the active image skill's dispatcher wording.
Do not require, request, or mention \`OPENAI_API_KEY\` before trying the
built-in path. Reuse the project metadata, reference prompt template, aspect
ratio, style notes, and the user's current brief to form the final image
prompt. Generate the image with Codex built-in imagegen, then use the actual
output path returned by the built-in imagegen result as the source file first.
Only if the built-in result does not return a usable path should you search
\`\${CODEX_HOME:-$HOME/.codex}/generated_images/.../ig_*.png\` as a fallback
source. Never leave a project-referenced asset only under \`$CODEX_HOME\`.
Copy or move the selected generated file into \`$OD_PROJECT_DIR\` with a short
descriptive filename, then verify the exact destination file exists under
\`$OD_PROJECT_DIR\` before claiming success. If reading the source path,
creating the destination directory, copying/moving, or verifying the copied
asset fails, report the exact source path, destination path, and access/copy
error. Do not claim success, silently fall back, or ask about OpenAI/Azure
fallback after a generated image exists but the project copy fails; stop after
reporting the failure unless the user explicitly chooses fallback in a later
turn, because fallback may create a different image.
After the file exists under \`$OD_PROJECT_DIR\`, reply with the project-local
filename and a short summary of the prompt used. Do not emit an \`<artifact>\`
block for media.
If Codex built-in imagegen is unavailable or generation fails before producing
an image, surface the actual failure message and ask the user for one-time
confirmation before falling back to the existing OpenAI/Azure API-key provider
path via \`"$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model ${imageModel}\`.
Do not silently fall back.`;
}
function renderMetadataBlock(
metadata: ProjectMetadata | undefined,
template: ProjectTemplate | undefined,

View file

@ -9,7 +9,11 @@ import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import net from 'node:net';
import { composeSystemPrompt } from './prompts/system.js';
import {
composeSystemPrompt,
renderCodexImagegenOverride,
shouldRenderCodexImagegenOverride,
} from './prompts/system.js';
import { createCommandInvocation } from '@open-design/platform';
import {
buildLiveArtifactsMcpServersForAgent,
@ -164,6 +168,267 @@ export function resolveDaemonCliPath(): string {
const PROJECT_ROOT = resolveProjectRoot(__dirname);
const RESOURCE_ROOT_ENV = 'OD_RESOURCE_ROOT';
export function composeLiveInstructionPrompt({
daemonSystemPrompt,
runtimeToolPrompt,
clientSystemPrompt,
finalPromptOverride,
}) {
const override =
typeof finalPromptOverride === 'string'
? finalPromptOverride.trim()
: '';
const parts = [daemonSystemPrompt, runtimeToolPrompt, clientSystemPrompt]
.map((part) => (typeof part === 'string' ? part.trim() : ''))
.map((part) =>
override && part.includes(override)
? part.split(override).join('').trim()
: part,
)
.filter(Boolean);
if (override) {
parts.push(override);
}
return parts.join('\n\n---\n\n');
}
export function resolveCodexGeneratedImagesDir(
agentId,
metadata,
env = process.env,
homeDir = os.homedir(),
) {
if (!shouldRenderCodexImagegenOverride(agentId, metadata)) return null;
const rawCodexHome =
typeof env?.CODEX_HOME === 'string' && env.CODEX_HOME.trim().length > 0
? env.CODEX_HOME.trim()
: path.join(homeDir, '.codex');
const codexHome = rawCodexHome.startsWith('~/')
? path.join(homeDir, rawCodexHome.slice(2))
: rawCodexHome;
return path.resolve(codexHome, 'generated_images');
}
type DirectoryStat = {
isDirectory(): boolean;
isSymbolicLink(): boolean;
};
type CodexGeneratedImagesDirValidationOptions = {
protectedDirs?: Array<string | null | undefined>;
mkdirSync?: (target: string, options: { recursive: true }) => unknown;
lstatSync?: (target: string) => DirectoryStat;
statSync?: (target: string) => DirectoryStat;
realpathSync?: (target: string) => string;
warn?: (message: string) => void;
};
function isMissingPathError(err: unknown): boolean {
return (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
);
}
function collectProtectedDirRoots(
protectedDirs: Array<string | null | undefined>,
{
realpathSync,
statSync,
}: {
realpathSync: (target: string) => string;
statSync: (target: string) => DirectoryStat;
},
): string[] {
const roots = [];
for (const raw of Array.isArray(protectedDirs) ? protectedDirs : []) {
if (typeof raw !== 'string' || raw.trim().length === 0) continue;
const resolved = path.resolve(raw);
roots.push(resolved);
try {
const canonical = realpathSync(resolved);
try {
if (statSync(canonical).isDirectory()) roots.push(canonical);
} catch {
roots.push(canonical);
}
} catch {
// A missing protected root cannot be the canonical target of a symlink.
}
}
return Array.from(new Set(roots));
}
function findContainingProtectedRoot(
candidate: string,
protectedRoots: string[],
): string | null {
return protectedRoots.find((root) => isPathWithin(root, candidate)) ?? null;
}
export function validateCodexGeneratedImagesDir(
codexGeneratedImagesDir: string | null | undefined,
{
protectedDirs = [],
mkdirSync = fs.mkdirSync,
lstatSync = fs.lstatSync,
statSync = fs.statSync,
realpathSync = fs.realpathSync.native,
warn = console.warn,
}: CodexGeneratedImagesDirValidationOptions = {},
): string | null {
if (
typeof codexGeneratedImagesDir !== 'string' ||
codexGeneratedImagesDir.trim().length === 0
) {
return null;
}
const resolved = path.resolve(codexGeneratedImagesDir);
const protectedRoots = collectProtectedDirRoots(protectedDirs, {
realpathSync,
statSync,
});
const warnSkipped = (reason: string) =>
warn(`[od] codex generated_images allowlist skipped: ${reason}`);
const protectedRoot = findContainingProtectedRoot(resolved, protectedRoots);
if (protectedRoot) {
warnSkipped(`${resolved} is inside protected root ${protectedRoot}`);
return null;
}
try {
let existingTargetStat = null;
try {
existingTargetStat = lstatSync(resolved);
} catch (err) {
if (!isMissingPathError(err)) throw err;
}
if (existingTargetStat?.isSymbolicLink()) {
warnSkipped(`${resolved} is a symlink`);
return null;
}
if (existingTargetStat && !existingTargetStat.isDirectory()) {
warnSkipped(`${resolved} is not a directory`);
return null;
}
const parent = path.dirname(resolved);
const protectedParentRoot = findContainingProtectedRoot(
parent,
protectedRoots,
);
if (protectedParentRoot) {
warnSkipped(`${parent} is inside protected root ${protectedParentRoot}`);
return null;
}
mkdirSync(parent, { recursive: true });
const canonicalParent = realpathSync(parent);
const canonicalCandidate = path.join(
canonicalParent,
path.basename(resolved),
);
const protectedCanonicalParentRoot = findContainingProtectedRoot(
canonicalCandidate,
protectedRoots,
);
if (protectedCanonicalParentRoot) {
warnSkipped(
`${canonicalCandidate} resolves inside protected root ${protectedCanonicalParentRoot}`,
);
return null;
}
mkdirSync(resolved, { recursive: true });
if (lstatSync(resolved).isSymbolicLink()) {
warnSkipped(`${resolved} is a symlink`);
return null;
}
if (!statSync(resolved).isDirectory()) {
warnSkipped(`${resolved} is not a directory`);
return null;
}
const canonicalDir = realpathSync(resolved);
const protectedCanonicalRoot = findContainingProtectedRoot(
canonicalDir,
protectedRoots,
);
if (protectedCanonicalRoot) {
warnSkipped(
`${canonicalDir} resolves inside protected root ${protectedCanonicalRoot}`,
);
return null;
}
return canonicalDir;
} catch (err) {
const message =
err instanceof Error ? err.message : String(err ?? 'unknown error');
warn(`[od] codex generated_images allowlist mkdir failed: ${message}`);
return null;
}
}
export function resolveChatExtraAllowedDirs({
agentId,
skillsDir,
designSystemsDir,
linkedDirs = [],
codexGeneratedImagesDir,
existsSync = fs.existsSync,
}: {
agentId?: string | null;
skillsDir?: string | null;
designSystemsDir?: string | null;
linkedDirs?: Array<string | null | undefined>;
codexGeneratedImagesDir?: string | null;
existsSync?: (path: string) => boolean;
}): string[] {
const isCodex =
typeof agentId === 'string' && agentId.trim().toLowerCase() === 'codex';
const candidates = isCodex
? [codexGeneratedImagesDir]
: [
skillsDir,
designSystemsDir,
...(Array.isArray(linkedDirs) ? linkedDirs : []),
];
return Array.from(
new Set(
candidates.filter(
(d) =>
typeof d === 'string' && d.length > 0 && existsSync(d),
),
),
);
}
export function resolveGrantedCodexImagegenOverride({
agentId,
metadata,
codexGeneratedImagesDir,
extraAllowedDirs = [],
}: {
agentId?: string | null;
metadata?: unknown;
codexGeneratedImagesDir?: string | null;
extraAllowedDirs?: string[];
}): string | null {
if (
typeof codexGeneratedImagesDir !== 'string' ||
codexGeneratedImagesDir.length === 0 ||
!Array.isArray(extraAllowedDirs) ||
!extraAllowedDirs.includes(codexGeneratedImagesDir)
) {
return null;
}
return renderCodexImagegenOverride(agentId, metadata);
}
export function normalizeCommentAttachments(input) {
if (!Array.isArray(input)) return [];
return input
@ -3241,6 +3506,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
};
const composeDaemonSystemPrompt = async ({
agentId,
projectId,
skillId,
designSystemId,
@ -3304,6 +3570,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
: undefined;
const prompt = composeSystemPrompt({
agentId,
includeCodexImagegenOverride: false,
skillBody,
skillName,
skillMode,
@ -3464,27 +3732,11 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
const commentHint = renderCommentAttachmentHint(safeCommentAttachments);
const { prompt: daemonSystemPrompt, activeSkillDir } =
await composeDaemonSystemPrompt({
agentId,
projectId,
skillId,
designSystemId,
});
const instructionPrompt = [daemonSystemPrompt, runtimeToolPrompt, systemPrompt]
.map((part) => (typeof part === 'string' ? part.trim() : ''))
.filter(Boolean)
.join('\n\n---\n\n');
const composed = [
instructionPrompt
? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}${linkedDirsHint}\n\n---\n`
: cwdHint
? `# Instructions${cwdHint}${linkedDirsHint}\n\n---\n`
: linkedDirsHint
? `# Instructions${linkedDirsHint}\n\n---\n`
: '',
`# User request\n\n${message || '(No extra typed instruction.)'}${attachmentHint}${commentHint}`,
safeImages.length
? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}`
: '',
].join('');
// Make skill side files reachable through three layers, in order of
// preference. The skill preamble emitted by `withSkillRootPreamble()`
@ -3497,10 +3749,13 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// path inside its working directory. We copy (not symlink) so
// the staged directory is a true write barrier — agents cannot
// mutate the shipped repo resource through their cwd.
// 2. `--add-dir` allowlist. Pass `SKILLS_DIR` and
// `DESIGN_SYSTEMS_DIR` to Claude/Copilot so the absolute fallback
// path in the preamble is reachable when staging fails (e.g. the
// project has no on-disk cwd, or fs.cp errored).
// 2. `--add-dir` allowlist. For non-Codex agents, pass `SKILLS_DIR`
// and `DESIGN_SYSTEMS_DIR` so the absolute fallback path in the
// preamble is reachable when staging fails (e.g. the project has
// no on-disk cwd, or fs.cp errored). Codex treats `--add-dir`
// entries as writable, so Codex receives only the narrow
// `${CODEX_HOME:-$HOME/.codex}/generated_images` output folder
// for allowlisted gpt-image image projects.
// 3. PROJECT_ROOT cwd. When `cwd` is null, the agent runs with
// `cwd: PROJECT_ROOT` — there the absolute path is already an
// in-cwd path, so neither (1) nor (2) is required for it to
@ -3531,11 +3786,50 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// no-project runs (packaged daemons / service launches do not start
// their working directory from the workspace root).
const effectiveCwd = cwd ?? PROJECT_ROOT;
const extraAllowedDirs = [
SKILLS_DIR,
DESIGN_SYSTEMS_DIR,
...linkedDirs,
].filter((d) => fs.existsSync(d));
let codexGeneratedImagesDir = resolveCodexGeneratedImagesDir(
agentId,
projectRecord?.metadata,
);
if (codexGeneratedImagesDir) {
codexGeneratedImagesDir = validateCodexGeneratedImagesDir(
codexGeneratedImagesDir,
{
protectedDirs: [SKILLS_DIR, DESIGN_SYSTEMS_DIR, ...linkedDirs],
},
);
}
const extraAllowedDirs = resolveChatExtraAllowedDirs({
agentId,
skillsDir: SKILLS_DIR,
designSystemsDir: DESIGN_SYSTEMS_DIR,
linkedDirs,
codexGeneratedImagesDir,
});
const codexImagegenOverride = resolveGrantedCodexImagegenOverride({
agentId,
metadata: projectRecord?.metadata,
codexGeneratedImagesDir,
extraAllowedDirs,
});
const instructionPrompt = composeLiveInstructionPrompt({
daemonSystemPrompt,
runtimeToolPrompt,
clientSystemPrompt: systemPrompt,
finalPromptOverride: codexImagegenOverride,
});
const composed = [
instructionPrompt
? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}${linkedDirsHint}\n\n---\n`
: cwdHint
? `# Instructions${cwdHint}${linkedDirsHint}\n\n---\n`
: linkedDirsHint
? `# Instructions${linkedDirsHint}\n\n---\n`
: '',
`# User request\n\n${message || '(No extra typed instruction.)'}${attachmentHint}${commentHint}`,
safeImages.length
? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}`
: '',
].join('');
// Per-agent model + reasoning the user picked in the model menu.
// Trust the value when it matches the most recent /api/agents listing
// (live or fallback). Otherwise allow it through if it passes a

View file

@ -170,6 +170,23 @@ test('codex args do not include the literal `-` stdin sentinel (regression of #2
assert.equal(withDisablePlugins.includes('-'), false);
});
test('codex args pass valid extraAllowedDirs with repeatable --add-dir flags', () => {
delete process.env.OD_CODEX_DISABLE_PLUGINS;
const args = codex.buildArgs(
'',
[],
['/repo/skills', '', null, '/tmp/codex/generated_images', undefined],
{},
{ cwd: '/tmp/od-project' },
);
assert.deepEqual(
args.filter((arg, index) => arg === '--add-dir' || args[index - 1] === '--add-dir'),
['--add-dir', '/repo/skills', '--add-dir', '/tmp/codex/generated_images'],
);
});
test('live artifact MCP discovery is limited to mature ACP agents', () => {
assert.deepEqual(buildLiveArtifactsMcpServersForAgent(hermes), [
{

View file

@ -1,9 +1,28 @@
import type http from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
import {
composeLiveInstructionPrompt,
resolveGrantedCodexImagegenOverride,
resolveCodexGeneratedImagesDir,
resolveChatExtraAllowedDirs,
startServer,
validateCodexGeneratedImagesDir,
} from '../src/server.js';
import { getAgentDef } from '../src/agents.js';
import { renderCodexImagegenOverride } from '../src/prompts/system.js';
function symlinkDir(target: string, link: string): void {
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
}
describe('/api/chat', () => {
let server: http.Server;
@ -62,3 +81,286 @@ describe('/api/chat', () => {
expect(body).toContain('AGENT_UNAVAILABLE');
});
});
describe('chat prompt helpers', () => {
it('appends the validated Codex override after the client system prompt and removes earlier duplicates', () => {
const override = renderCodexImagegenOverride('codex', {
kind: 'image',
imageModel: 'gpt-image-2',
imageAspect: '1:1',
});
const clientMediaContract =
'## Media generation contract\nclient contract wins unless a later override says otherwise';
const prompt = composeLiveInstructionPrompt({
daemonSystemPrompt: `daemon prompt\n${override}`,
runtimeToolPrompt: 'runtime tools',
clientSystemPrompt: clientMediaContract,
finalPromptOverride: override,
});
const clientIdx = prompt.indexOf(clientMediaContract);
const overrideIdx = prompt.indexOf('## Codex built-in imagegen override');
expect(clientIdx).toBeGreaterThan(-1);
expect(overrideIdx).toBeGreaterThan(clientIdx);
expect(prompt.match(/## Codex built-in imagegen override/g)).toHaveLength(1);
});
it('resolves only the narrow Codex generated_images allowlist for known gpt-image image projects', () => {
expect(
resolveCodexGeneratedImagesDir(
'codex',
{ kind: 'image', imageModel: 'gpt-image-2' },
{ CODEX_HOME: '/tmp/custom-codex-home' },
'/home/tester',
),
).toBe('/tmp/custom-codex-home/generated_images');
expect(
resolveCodexGeneratedImagesDir(
'codex',
{ kind: 'image', imageModel: 'gpt-image-2-preview' },
{ CODEX_HOME: '/tmp/custom-codex-home' },
'/home/tester',
),
).toBeNull();
expect(
resolveCodexGeneratedImagesDir(
'claude',
{ kind: 'image', imageModel: 'gpt-image-2' },
{ CODEX_HOME: '/tmp/custom-codex-home' },
'/home/tester',
),
).toBeNull();
});
it('rejects a generated_images final-component symlink', () => {
const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-symlink-'));
try {
const codexHome = join(root, 'codex-home');
const symlinkTarget = join(root, 'actual-generated-images');
mkdirSync(codexHome, { recursive: true });
mkdirSync(symlinkTarget, { recursive: true });
symlinkDir(symlinkTarget, join(codexHome, 'generated_images'));
const generatedImagesDir = resolveCodexGeneratedImagesDir(
'codex',
{ kind: 'image', imageModel: 'gpt-image-2' },
{ CODEX_HOME: codexHome },
'/home/tester',
);
expect(
validateCodexGeneratedImagesDir(generatedImagesDir, {
warn: () => undefined,
}),
).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('rejects a generated_images dir whose canonical path is inside a protected root', () => {
const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-protected-'));
try {
const protectedRoot = join(root, 'skills');
const protectedGeneratedImages = join(protectedRoot, 'generated_images');
mkdirSync(protectedGeneratedImages, { recursive: true });
const codexHome = join(root, 'codex-home');
symlinkDir(protectedRoot, codexHome);
const generatedImagesDir = resolveCodexGeneratedImagesDir(
'codex',
{ kind: 'image', imageModel: 'gpt-image-2' },
{ CODEX_HOME: codexHome },
'/home/tester',
);
expect(
validateCodexGeneratedImagesDir(generatedImagesDir, {
protectedDirs: [protectedRoot],
warn: () => undefined,
}),
).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('grants Codex the canonical validated generated_images dir', () => {
const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-canonical-'));
try {
const actualCodexHome = join(root, 'actual-codex-home');
const symlinkCodexHome = join(root, 'codex-home-link');
mkdirSync(actualCodexHome, { recursive: true });
symlinkDir(actualCodexHome, symlinkCodexHome);
const generatedImagesDir = resolveCodexGeneratedImagesDir(
'codex',
{ kind: 'image', imageModel: 'gpt-image-2' },
{ CODEX_HOME: symlinkCodexHome },
'/home/tester',
);
const validatedDir = validateCodexGeneratedImagesDir(
generatedImagesDir,
{ warn: () => undefined },
);
const canonicalGeneratedImagesDir = join(
realpathSync.native(actualCodexHome),
'generated_images',
);
const extraAllowedDirs = resolveChatExtraAllowedDirs({
agentId: 'codex',
skillsDir: '/repo/skills',
designSystemsDir: '/repo/design-systems',
linkedDirs: ['/linked/reference'],
codexGeneratedImagesDir: validatedDir,
existsSync: () => true,
});
const codex = getAgentDef('codex');
if (!codex) throw new Error('Codex agent definition missing');
const args = codex.buildArgs('', [], extraAllowedDirs, {}, {
cwd: '/tmp/od-project',
});
expect(generatedImagesDir).not.toBe(canonicalGeneratedImagesDir);
expect(validatedDir).toBe(canonicalGeneratedImagesDir);
expect(extraAllowedDirs).toEqual([canonicalGeneratedImagesDir]);
expect(
args.filter(
(arg, index) =>
arg === '--add-dir' || args[index - 1] === '--add-dir',
),
).toEqual(['--add-dir', canonicalGeneratedImagesDir]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('limits Codex extra allowed dirs to the generated_images output dir', () => {
const generatedImagesDir = '/home/tester/.codex/generated_images';
const dirs = resolveChatExtraAllowedDirs({
agentId: ' CoDeX ',
skillsDir: '/repo/skills',
designSystemsDir: '/repo/design-systems',
linkedDirs: ['/linked/reference'],
codexGeneratedImagesDir: generatedImagesDir,
existsSync: () => true,
});
expect(dirs).toEqual([generatedImagesDir]);
const codex = getAgentDef('codex');
if (!codex) throw new Error('Codex agent definition missing');
const args = codex.buildArgs('', [], dirs, {}, { cwd: '/tmp/od-project' });
expect(
args.filter(
(arg, index) =>
arg === '--add-dir' || args[index - 1] === '--add-dir',
),
).toEqual(['--add-dir', generatedImagesDir]);
expect(args).not.toContain('/repo/skills');
expect(args).not.toContain('/repo/design-systems');
expect(args).not.toContain('/linked/reference');
});
it('keeps resource and linked dirs for non-Codex agents without the Codex output dir', () => {
const existingDirs = new Set([
'/repo/skills',
'/repo/design-systems',
'/linked/reference',
'/home/tester/.codex/generated_images',
]);
const dirs = resolveChatExtraAllowedDirs({
agentId: 'claude',
skillsDir: '/repo/skills',
designSystemsDir: '/repo/design-systems',
linkedDirs: ['/linked/reference'],
codexGeneratedImagesDir: '/home/tester/.codex/generated_images',
existsSync: (dir: string) => existingDirs.has(dir),
});
expect(dirs).toEqual([
'/repo/skills',
'/repo/design-systems',
'/linked/reference',
]);
});
it('does not add resource dirs for Codex when imagegen is not whitelisted', () => {
const dirs = resolveChatExtraAllowedDirs({
agentId: 'codex',
skillsDir: '/repo/skills',
designSystemsDir: '/repo/design-systems',
linkedDirs: ['/linked/reference'],
codexGeneratedImagesDir: null,
existsSync: () => true,
});
expect(dirs).toEqual([]);
});
it('omits the Codex override when validation fails or the dir is not granted', () => {
const metadata = { kind: 'image', imageModel: 'gpt-image-2' };
const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-prompt-'));
try {
const codexHome = join(root, 'codex-home');
const symlinkTarget = join(root, 'actual-generated-images');
mkdirSync(codexHome, { recursive: true });
mkdirSync(symlinkTarget, { recursive: true });
symlinkDir(symlinkTarget, join(codexHome, 'generated_images'));
const generatedImagesDir = resolveCodexGeneratedImagesDir(
'codex',
metadata,
{ CODEX_HOME: codexHome },
'/home/tester',
);
const validatedDir = validateCodexGeneratedImagesDir(
generatedImagesDir,
{ warn: () => undefined },
);
const extraAllowedDirs = resolveChatExtraAllowedDirs({
agentId: 'codex',
skillsDir: '/repo/skills',
designSystemsDir: '/repo/design-systems',
linkedDirs: ['/linked/reference'],
codexGeneratedImagesDir: validatedDir,
existsSync: () => true,
});
const validationFailedOverride = resolveGrantedCodexImagegenOverride({
agentId: 'codex',
metadata,
codexGeneratedImagesDir: validatedDir,
extraAllowedDirs,
});
const validationFailedPrompt = composeLiveInstructionPrompt({
daemonSystemPrompt: 'daemon prompt',
runtimeToolPrompt: 'runtime tools',
clientSystemPrompt: 'client media contract',
finalPromptOverride: validationFailedOverride,
});
expect(validatedDir).toBeNull();
expect(extraAllowedDirs).toEqual([]);
expect(validationFailedOverride).toBeNull();
expect(validationFailedPrompt).not.toContain(
'## Codex built-in imagegen override',
);
const validDir = join(root, 'safe-codex-home', 'generated_images');
mkdirSync(validDir, { recursive: true });
const notGrantedOverride = resolveGrantedCodexImagegenOverride({
agentId: 'codex',
metadata,
codexGeneratedImagesDir: validDir,
extraAllowedDirs: [],
});
expect(notGrantedOverride).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { composeSystemPrompt } from '../src/prompts/system.js';
import {
composeSystemPrompt,
renderCodexImagegenOverride,
resolveCodexImagegenModelId,
} from '../src/prompts/system.js';
// These tests pin the rendering of metadata.promptTemplate inside the
// composed system prompt. The composer is the trust boundary between the
@ -182,4 +186,138 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
expect(out).toContain(baseSummary.prompt);
expect(out).not.toContain('Source:');
});
it('adds a Codex-only built-in imagegen override for gpt-image image projects', () => {
const out = composeSystemPrompt({
agentId: 'codex',
metadata: {
kind: 'image',
imageModel: 'gpt-image-2',
imageAspect: '1:1',
promptTemplate: { ...baseSummary },
},
});
const mediaContractIdx = out.indexOf('## Media generation contract');
const codexOverrideIdx = out.indexOf('## Codex built-in imagegen override');
expect(mediaContractIdx).toBeGreaterThan(-1);
expect(codexOverrideIdx).toBeGreaterThan(mediaContractIdx);
expect(out).toContain('use Codex\'s built-in image generation capability');
expect(out).toContain('intentional exception to the media generation contract');
expect(out).toContain('Do not require, request, or mention `OPENAI_API_KEY`');
expect(out).toContain('Generate the image with Codex built-in imagegen');
expect(out).toMatch(
/actual\s+output path returned by the built-in imagegen result/,
);
expect(out).toContain('${CODEX_HOME:-$HOME/.codex}/generated_images/.../ig_*.png');
expect(out).toContain('verify the exact destination file exists under');
expect(out).toMatch(
/report the exact source path, destination path, and access\/copy\s+error/,
);
expect(out).toContain('Do not claim success, silently fall back, or ask about OpenAI/Azure');
expect(out).toMatch(
/unless the user explicitly chooses fallback in a later\s+turn/,
);
expect(out).toContain('$OD_PROJECT_DIR');
expect(out).toMatch(/ask the user for one-time\s+confirmation/);
expect(out).toContain('"$OD_NODE_BIN" "$OD_BIN"');
expect(out).toContain('media generate --surface image --model gpt-image-2');
expect(out).toContain('Do not silently fall');
});
it('keeps non-Codex image projects on the daemon media dispatcher contract', () => {
const out = composeSystemPrompt({
agentId: 'claude',
metadata: {
kind: 'image',
imageModel: 'gpt-image-2',
imageAspect: '1:1',
promptTemplate: { ...baseSummary },
},
});
expect(out).toContain('## Media generation contract');
expect(out).toContain(
'"$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model <imageModel>',
);
expect(out).not.toContain('Do not require, request, or mention `OPENAI_API_KEY`');
expect(out).not.toContain('## Codex built-in imagegen override');
});
it('normalizes Codex agent selection before applying the imagegen override', () => {
const out = composeSystemPrompt({
agentId: ' CoDeX ',
metadata: {
kind: 'image',
imageModel: 'gpt-image-2',
imageAspect: '1:1',
promptTemplate: { ...baseSummary },
},
});
expect(out).toContain('## Codex built-in imagegen override');
expect(out).toContain('use Codex\'s built-in image generation capability');
});
it('can omit the Codex imagegen override so live chat appends it after the client system prompt', () => {
const out = composeSystemPrompt({
agentId: 'codex',
includeCodexImagegenOverride: false,
metadata: {
kind: 'image',
imageModel: 'gpt-image-2',
imageAspect: '1:1',
promptTemplate: { ...baseSummary },
},
});
expect(out).toContain('## Media generation contract');
expect(out).not.toContain('## Codex built-in imagegen override');
});
it('does not add the Codex imagegen override for non-gpt-image models', () => {
const out = composeSystemPrompt({
agentId: 'codex',
metadata: {
kind: 'image',
imageModel: 'grok-imagine-image',
imageAspect: '1:1',
promptTemplate: { ...baseSummary, model: 'grok-imagine-image' },
},
});
expect(out).toContain('## Media generation contract');
expect(out).not.toContain('## Codex built-in imagegen override');
});
it('does not render a Codex override for unrecognized gpt-image-like request metadata', () => {
const override = renderCodexImagegenOverride('codex', {
kind: 'image',
imageModel: 'gpt-image-2-preview-not-whitelisted',
imageAspect: '1:1',
});
expect(override).toBe('');
});
it('resolves only known OpenAI gpt-image model ids for the Codex override', () => {
expect(
resolveCodexImagegenModelId({
kind: 'image',
imageModel: 'gpt-image-2',
}),
).toBe('gpt-image-2');
expect(
resolveCodexImagegenModelId({
kind: 'image',
imageModel: 'dall-e-3',
}),
).toBe('');
expect(
resolveCodexImagegenModelId({
kind: 'image',
imageModel: 'gpt-image-2-preview-not-whitelisted',
}),
).toBe('');
});
});