@skelm/codex 0.4.1 → 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 +2 -2
- package/dist/backend.js +94 -12
- package/dist/client.js +3 -2
- package/dist/types.d.ts +8 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ export default defineConfig({
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
```ts
|
|
61
|
-
// codex-smoke.pipeline.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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>",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"exports": {
|
|
28
28
|
".": {
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
|
-
"
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
"@openai/codex-sdk": "^0.130.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@skelm/core": "^0.4.
|
|
52
|
+
"@skelm/core": "^0.4.3"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@skelm/core": "^0.4.
|
|
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"
|