@skelm/codex 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,7 @@ export default defineConfig({
58
58
  ```
59
59
 
60
60
  ```ts
61
- // codex-smoke.pipeline.ts
61
+ // codex-smoke.pipeline.mts
62
62
  import { agent, pipeline } from 'skelm'
63
63
  import { z } from 'zod'
64
64
 
@@ -84,7 +84,7 @@ export default pipeline({
84
84
  ```
85
85
 
86
86
  ```bash
87
- skelm run codex-smoke.pipeline.ts --input '{"task":"say ok"}'
87
+ skelm run codex-smoke.pipeline.mts --input '{"task":"say ok"}'
88
88
  ```
89
89
 
90
90
  ## Permission mapping
package/dist/backend.js CHANGED
@@ -1,4 +1,7 @@
1
- import { buildSystemPromptFromRequest, loadSkillBodies, resolvePermissions } from '@skelm/core';
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { BackendConfigError, buildSystemPromptFromRequest, extractPromptText, loadSkillBodies, resolvePermissions, } from '@skelm/core';
2
5
  import { buildCodexOptions, buildMcpServerConfig, buildThreadOptions, consumeStream, makeCodexClient, } from './client.js';
3
6
  import { mapPermissionsToCodex } from './permission-mapper.js';
4
7
  /**
@@ -30,6 +33,12 @@ export function createCodexBackend(options = {}) {
30
33
  // Skelm checks at the boundary (refusing unsafe combinations before any
31
34
  // Codex call); Codex enforces at runtime.
32
35
  toolPermissions: 'native',
36
+ // Image content is forwarded as `{type:'local_image', path}` per the
37
+ // codex-sdk schema; bytes are materialized to a temp file for the turn
38
+ // and cleaned up afterwards. Whether the configured codex model can
39
+ // actually process images is up to the model — non-vision models will
40
+ // surface a provider error that propagates as a step failure.
41
+ vision: options.vision ?? true,
33
42
  };
34
43
  const backend = {
35
44
  id: options.id ?? 'codex',
@@ -51,18 +60,10 @@ export function createCodexBackend(options = {}) {
51
60
  policy,
52
61
  ...(request.cwd !== undefined && { workingDirectory: request.cwd }),
53
62
  });
54
- // Filter requested MCP servers through the allowlist.
63
+ // Filter requested MCP servers through the allowlist; the runner's
64
+ // audit writer is the single durable record for denials.
55
65
  const allowed = filterAllowedMcp(request.mcpServers, policy.allowedMcpServers);
56
66
  const mcpConfig = buildMcpServerConfig(allowed.allowed);
57
- const deniedMcp = allowed.denied.map((s) => s.id);
58
- // Audit-only for now; the runner's audit writer is the durable record.
59
- const logDenial = (dimension, ids, reason) => console.warn(JSON.stringify({ event: 'permission.denied', dimension, ids, reason, backend: 'codex' }));
60
- if (deniedMcp.length > 0) {
61
- logDenial('mcp', deniedMcp, 'not-in-allowlist');
62
- }
63
- if (mcpConfig !== null && mcpConfig.dropped.length > 0) {
64
- logDenial('mcp', mcpConfig.dropped, 'transport-unsupported');
65
- }
66
67
  // Construct the SDK client with config + proxy env. Only forward
67
68
  // `mcp_servers` to Codex — never leak the `dropped` bookkeeping field.
68
69
  const codexOpts = buildCodexOptions(options, {
@@ -73,7 +74,19 @@ export function createCodexBackend(options = {}) {
73
74
  // Compose the system prompt via @skelm/core's shared builder so
74
75
  // systemPromptMode / systemPromptIncludeAgentDef take effect here.
75
76
  const systemPrompt = await composeSystemPrompt(request, context, options.model);
76
- const userPrompt = systemPrompt === undefined ? request.prompt : `${systemPrompt}\n\n---\n\n${request.prompt}`;
77
+ // Codex SDK accepts string OR Array<{type:'text'}|{type:'local_image',path}>.
78
+ // For image-bearing prompts we materialize each image to a temp file
79
+ // (Codex requires filesystem paths, not data URLs) and clean up after
80
+ // the turn. Pure-text prompts keep the prior compact "<system>\n\n---\n\n<text>" shape.
81
+ const imageRoots = [];
82
+ const userPrompt = typeof request.prompt === 'string' || extractImageParts(request.prompt).length === 0
83
+ ? (() => {
84
+ const promptText = extractPromptText(request.prompt);
85
+ return systemPrompt === undefined
86
+ ? promptText
87
+ : `${systemPrompt}\n\n---\n\n${promptText}`;
88
+ })()
89
+ : buildCodexMultimodalInput(request.prompt, systemPrompt, imageRoots);
77
90
  // Build the thread (resume vs fresh) honoring per-step sandbox/approval.
78
91
  const threadOpts = buildThreadOptions(options, {
79
92
  sandboxMode: mapped.sandboxMode,
@@ -108,6 +121,7 @@ export function createCodexBackend(options = {}) {
108
121
  }
109
122
  finally {
110
123
  turnSignal.cancel();
124
+ cleanupTempImageRoots(imageRoots);
111
125
  }
112
126
  const response = {
113
127
  text: result.finalText,
@@ -129,6 +143,74 @@ export function createCodexBackend(options = {}) {
129
143
  };
130
144
  return backend;
131
145
  }
146
+ function extractImageParts(prompt) {
147
+ if (typeof prompt === 'string')
148
+ return [];
149
+ return prompt.filter((p) => p.type === 'image');
150
+ }
151
+ function mimeToExt(mime) {
152
+ switch (mime) {
153
+ case 'image/png':
154
+ return '.png';
155
+ case 'image/jpeg':
156
+ return '.jpg';
157
+ case 'image/webp':
158
+ return '.webp';
159
+ case 'image/gif':
160
+ return '.gif';
161
+ default:
162
+ return '.bin';
163
+ }
164
+ }
165
+ function buildCodexMultimodalInput(prompt, systemPrompt, imageRoots) {
166
+ const tmp = mkdtempSync(join(tmpdir(), 'skelm-codex-img-'));
167
+ imageRoots.push(tmp);
168
+ const parts = [];
169
+ let imgIdx = 0;
170
+ // Seed the text buffer with the system prompt block so it always lands as
171
+ // the FIRST text part — even when the prompt is `[image, text, ...]` and we
172
+ // would otherwise flush a `local_image` before seeing any user text.
173
+ // Mirrors the pure-text fallback `"<system>\n\n---\n\n<text>"` higher up,
174
+ // so callers see consistent ordering regardless of which path the request
175
+ // takes.
176
+ let textBuf = systemPrompt !== undefined ? `${systemPrompt}\n\n---\n\n` : '';
177
+ if (typeof prompt === 'string') {
178
+ parts.push({ type: 'text', text: `${textBuf}${prompt}` });
179
+ return parts;
180
+ }
181
+ for (const part of prompt) {
182
+ if (part.type === 'text') {
183
+ textBuf += part.text;
184
+ }
185
+ else if (part.type === 'image') {
186
+ if (textBuf.length > 0) {
187
+ parts.push({ type: 'text', text: textBuf });
188
+ textBuf = '';
189
+ }
190
+ const file = join(tmp, `img${imgIdx++}${mimeToExt(part.mimeType)}`);
191
+ try {
192
+ writeFileSync(file, Buffer.from(part.data, 'base64'));
193
+ }
194
+ catch (err) {
195
+ throw new BackendConfigError(`codex backend: failed to materialize image to ${file}: ${err.message}`, 'codex');
196
+ }
197
+ parts.push({ type: 'local_image', path: file });
198
+ }
199
+ }
200
+ if (textBuf.length > 0)
201
+ parts.push({ type: 'text', text: textBuf });
202
+ return parts;
203
+ }
204
+ function cleanupTempImageRoots(roots) {
205
+ for (const root of roots) {
206
+ try {
207
+ rmSync(root, { recursive: true, force: true });
208
+ }
209
+ catch {
210
+ // Best-effort cleanup; OS will eventually reap /tmp anyway.
211
+ }
212
+ }
213
+ }
132
214
  function filterAllowedMcp(servers, allowlist) {
133
215
  if (servers === undefined || servers.length === 0)
134
216
  return { allowed: [], denied: [] };
package/dist/client.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * can publish onto skelm's event bus and `onPartial` callback.
9
9
  */
10
10
  import { Codex } from '@openai/codex-sdk';
11
+ import { BackendUpstreamError } from '@skelm/core';
11
12
  /** Build CodexOptions from CodexBackendOptions + per-run overrides. */
12
13
  export function buildCodexOptions(opts, overrides = {}) {
13
14
  const out = {};
@@ -133,11 +134,11 @@ export async function consumeStream(events, callbacks) {
133
134
  case 'turn.failed':
134
135
  stopReason = 'turn.failed';
135
136
  callbacks.onError?.(ev.error.message);
136
- throw new Error(`codex turn failed: ${ev.error.message}`);
137
+ throw new BackendUpstreamError(`codex turn failed: ${ev.error.message}`, 'codex');
137
138
  case 'error':
138
139
  stopReason = 'error';
139
140
  callbacks.onError?.(ev.message);
140
- throw new Error(`codex stream error: ${ev.message}`);
141
+ throw new BackendUpstreamError(`codex stream error: ${ev.message}`, 'codex');
141
142
  // thread.started, turn.started, item.started, item.updated: not
142
143
  // material to the final response; surface via onItem if the caller
143
144
  // wants per-item audit (it doesn't, by default).
package/dist/types.d.ts CHANGED
@@ -38,6 +38,14 @@ export interface CodexBackendOptions {
38
38
  * cancellation; this is a defensive ceiling. Default: 300_000 (5 min).
39
39
  */
40
40
  timeoutMs?: number;
41
+ /**
42
+ * Advertise `capabilities.vision`. Defaults to `true`: image content is
43
+ * materialized to a temp file and forwarded as `{type:'local_image', path}`
44
+ * per the codex-sdk Input schema. Set `false` for codex configurations
45
+ * pinned to a known text-only model — the framework gate then rejects
46
+ * image prompts at step start with no codex turn ever started.
47
+ */
48
+ vision?: boolean;
41
49
  }
42
50
  /**
43
51
  * Resolved Codex SDK options after skelm permissions are applied. Produced
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skelm/codex",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "OpenAI Codex backend for skelm via the official @openai/codex-sdk",
5
5
  "license": "MIT",
6
6
  "author": "Scott Glover <scottgl@gmail.com>",
@@ -49,10 +49,10 @@
49
49
  "@openai/codex-sdk": "^0.130.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@skelm/core": "^0.4.2"
52
+ "@skelm/core": "^0.4.3"
53
53
  },
54
54
  "devDependencies": {
55
- "@skelm/core": "^0.4.2",
55
+ "@skelm/core": "^0.4.3",
56
56
  "@types/node": "^20.10.0",
57
57
  "typescript": "^5.3.0",
58
58
  "vitest": "^2.1.5"