mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
c8127b78fd
commit
09b78c2f9b
7 changed files with 890 additions and 36 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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), [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue