@agentstep/agent-sdk 0.1.0
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/package.json +45 -0
- package/src/auth/middleware.ts +38 -0
- package/src/backends/claude/args.ts +88 -0
- package/src/backends/claude/index.ts +193 -0
- package/src/backends/claude/permission-hook.ts +152 -0
- package/src/backends/claude/tool-bridge.ts +211 -0
- package/src/backends/claude/translator.ts +209 -0
- package/src/backends/claude/wrapper-script.ts +45 -0
- package/src/backends/codex/args.ts +69 -0
- package/src/backends/codex/auth.ts +35 -0
- package/src/backends/codex/index.ts +57 -0
- package/src/backends/codex/setup.ts +37 -0
- package/src/backends/codex/translator.ts +223 -0
- package/src/backends/codex/wrapper-script.ts +26 -0
- package/src/backends/factory/args.ts +45 -0
- package/src/backends/factory/auth.ts +30 -0
- package/src/backends/factory/index.ts +56 -0
- package/src/backends/factory/setup.ts +34 -0
- package/src/backends/factory/translator.ts +139 -0
- package/src/backends/factory/wrapper-script.ts +33 -0
- package/src/backends/gemini/args.ts +44 -0
- package/src/backends/gemini/auth.ts +30 -0
- package/src/backends/gemini/index.ts +53 -0
- package/src/backends/gemini/setup.ts +34 -0
- package/src/backends/gemini/translator.ts +139 -0
- package/src/backends/gemini/wrapper-script.ts +26 -0
- package/src/backends/opencode/args.ts +53 -0
- package/src/backends/opencode/auth.ts +53 -0
- package/src/backends/opencode/index.ts +70 -0
- package/src/backends/opencode/mcp.ts +67 -0
- package/src/backends/opencode/setup.ts +54 -0
- package/src/backends/opencode/translator.ts +168 -0
- package/src/backends/opencode/wrapper-script.ts +46 -0
- package/src/backends/registry.ts +38 -0
- package/src/backends/shared/ndjson.ts +29 -0
- package/src/backends/shared/translator-types.ts +69 -0
- package/src/backends/shared/wrap-prompt.ts +17 -0
- package/src/backends/types.ts +85 -0
- package/src/config/index.ts +95 -0
- package/src/db/agents.ts +185 -0
- package/src/db/api_keys.ts +78 -0
- package/src/db/batch.ts +142 -0
- package/src/db/client.ts +81 -0
- package/src/db/environments.ts +127 -0
- package/src/db/events.ts +208 -0
- package/src/db/memory.ts +143 -0
- package/src/db/migrations.ts +295 -0
- package/src/db/proxy.ts +37 -0
- package/src/db/sessions.ts +295 -0
- package/src/db/vaults.ts +110 -0
- package/src/errors.ts +53 -0
- package/src/handlers/agents.ts +194 -0
- package/src/handlers/batch.ts +41 -0
- package/src/handlers/docs.ts +87 -0
- package/src/handlers/environments.ts +154 -0
- package/src/handlers/events.ts +234 -0
- package/src/handlers/index.ts +12 -0
- package/src/handlers/memory.ts +141 -0
- package/src/handlers/openapi.ts +14 -0
- package/src/handlers/sessions.ts +223 -0
- package/src/handlers/stream.ts +76 -0
- package/src/handlers/threads.ts +26 -0
- package/src/handlers/ui/app.js +984 -0
- package/src/handlers/ui/index.html +112 -0
- package/src/handlers/ui/style.css +164 -0
- package/src/handlers/ui.ts +1281 -0
- package/src/handlers/vaults.ts +99 -0
- package/src/http.ts +35 -0
- package/src/index.ts +104 -0
- package/src/init.ts +227 -0
- package/src/openapi/registry.ts +8 -0
- package/src/openapi/schemas.ts +625 -0
- package/src/openapi/spec.ts +691 -0
- package/src/providers/apple.ts +220 -0
- package/src/providers/daytona.ts +217 -0
- package/src/providers/docker.ts +264 -0
- package/src/providers/e2b.ts +203 -0
- package/src/providers/fly.ts +276 -0
- package/src/providers/modal.ts +222 -0
- package/src/providers/podman.ts +206 -0
- package/src/providers/registry.ts +28 -0
- package/src/providers/shared.ts +11 -0
- package/src/providers/sprites.ts +55 -0
- package/src/providers/types.ts +73 -0
- package/src/providers/vercel.ts +208 -0
- package/src/proxy/forward.ts +111 -0
- package/src/queue/index.ts +111 -0
- package/src/sessions/actor.ts +53 -0
- package/src/sessions/bus.ts +155 -0
- package/src/sessions/driver.ts +818 -0
- package/src/sessions/grader.ts +120 -0
- package/src/sessions/interrupt.ts +14 -0
- package/src/sessions/sweeper.ts +136 -0
- package/src/sessions/threads.ts +126 -0
- package/src/sessions/tools.ts +50 -0
- package/src/shutdown.ts +78 -0
- package/src/sprite/client.ts +294 -0
- package/src/sprite/exec.ts +161 -0
- package/src/sprite/lifecycle.ts +339 -0
- package/src/sprite/pool.ts +65 -0
- package/src/sprite/setup.ts +159 -0
- package/src/state.ts +61 -0
- package/src/types.ts +339 -0
- package/src/util/clock.ts +7 -0
- package/src/util/ids.ts +11 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom tool bridge: generates a synthetic MCP stdio server that exposes
|
|
3
|
+
* Managed Agents custom tools to claude inside the container.
|
|
4
|
+
*
|
|
5
|
+
* Architecture:
|
|
6
|
+
* - A Node.js script that implements the MCP stdio protocol (JSON-RPC on
|
|
7
|
+
* stdin/stdout)
|
|
8
|
+
* - Reads tool definitions from /tmp/tool-bridge/tools.json
|
|
9
|
+
* - On tool call: checks for pre-existing response.json (replay after
|
|
10
|
+
* --resume) and returns immediately. Otherwise writes request.json +
|
|
11
|
+
* creates pending sentinel, then watches for response.json via
|
|
12
|
+
* fs.watchFile and returns the result.
|
|
13
|
+
*
|
|
14
|
+
* The driver detects custom_tool_use via the translator's sawCustomToolUse()
|
|
15
|
+
* flag and stop_reason:"custom_tool_call". On user.custom_tool_result
|
|
16
|
+
* re-entry, the driver writes response.json and removes the pending sentinel
|
|
17
|
+
* before calling --resume.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { CustomTool } from "../../types";
|
|
21
|
+
|
|
22
|
+
export const TOOL_BRIDGE_DIR = "/tmp/tool-bridge";
|
|
23
|
+
export const TOOL_BRIDGE_SCRIPT_PATH = `${TOOL_BRIDGE_DIR}/bridge.mjs`;
|
|
24
|
+
export const TOOL_BRIDGE_TOOLS_PATH = `${TOOL_BRIDGE_DIR}/tools.json`;
|
|
25
|
+
export const TOOL_BRIDGE_REQUEST_PATH = `${TOOL_BRIDGE_DIR}/request.json`;
|
|
26
|
+
export const TOOL_BRIDGE_RESPONSE_PATH = `${TOOL_BRIDGE_DIR}/response.json`;
|
|
27
|
+
export const TOOL_BRIDGE_PENDING_PATH = `${TOOL_BRIDGE_DIR}/pending`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate the MCP stdio server script as a string.
|
|
31
|
+
* This script is written to the container and run by claude's --mcp-config.
|
|
32
|
+
*/
|
|
33
|
+
export function generateBridgeScript(): string {
|
|
34
|
+
return `#!/usr/bin/env node
|
|
35
|
+
// Auto-generated MCP stdio server for custom tool bridge.
|
|
36
|
+
// Reads tool definitions from ${TOOL_BRIDGE_TOOLS_PATH}
|
|
37
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, watch, watchFile, unwatchFile } from 'node:fs';
|
|
38
|
+
import { createInterface } from 'node:readline';
|
|
39
|
+
|
|
40
|
+
const TOOLS_PATH = ${JSON.stringify(TOOL_BRIDGE_TOOLS_PATH)};
|
|
41
|
+
const REQUEST_PATH = ${JSON.stringify(TOOL_BRIDGE_REQUEST_PATH)};
|
|
42
|
+
const RESPONSE_PATH = ${JSON.stringify(TOOL_BRIDGE_RESPONSE_PATH)};
|
|
43
|
+
const PENDING_PATH = ${JSON.stringify(TOOL_BRIDGE_PENDING_PATH)};
|
|
44
|
+
|
|
45
|
+
let tools = [];
|
|
46
|
+
try { tools = JSON.parse(readFileSync(TOOLS_PATH, 'utf8')); } catch {}
|
|
47
|
+
|
|
48
|
+
function sendResponse(id, result) {
|
|
49
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
50
|
+
const buf = Buffer.from(msg, 'utf8');
|
|
51
|
+
process.stdout.write('Content-Length: ' + buf.length + '\\r\\n\\r\\n');
|
|
52
|
+
process.stdout.write(buf);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sendError(id, code, message) {
|
|
56
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
|
|
57
|
+
const buf = Buffer.from(msg, 'utf8');
|
|
58
|
+
process.stdout.write('Content-Length: ' + buf.length + '\\r\\n\\r\\n');
|
|
59
|
+
process.stdout.write(buf);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleRequest(req) {
|
|
63
|
+
if (req.method === 'initialize') {
|
|
64
|
+
sendResponse(req.id, {
|
|
65
|
+
protocolVersion: '2024-11-05',
|
|
66
|
+
capabilities: { tools: { listChanged: false } },
|
|
67
|
+
serverInfo: { name: 'tool-bridge', version: '1.0.0' },
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (req.method === 'notifications/initialized') return;
|
|
72
|
+
if (req.method === 'tools/list') {
|
|
73
|
+
sendResponse(req.id, {
|
|
74
|
+
tools: tools.map(t => ({
|
|
75
|
+
name: t.name,
|
|
76
|
+
description: t.description || '',
|
|
77
|
+
inputSchema: t.input_schema || { type: 'object', properties: {} },
|
|
78
|
+
})),
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (req.method === 'tools/call') {
|
|
83
|
+
const toolName = req.params?.name;
|
|
84
|
+
const toolInput = req.params?.arguments || {};
|
|
85
|
+
|
|
86
|
+
// Replay case: if response.json already exists (from a --resume re-entry),
|
|
87
|
+
// return it immediately without creating a pending sentinel.
|
|
88
|
+
if (existsSync(RESPONSE_PATH)) {
|
|
89
|
+
try {
|
|
90
|
+
const resp = JSON.parse(readFileSync(RESPONSE_PATH, 'utf8'));
|
|
91
|
+
sendResponse(req.id, {
|
|
92
|
+
content: [{ type: 'text', text: JSON.stringify(resp.content ?? resp) }],
|
|
93
|
+
isError: false,
|
|
94
|
+
});
|
|
95
|
+
try { unlinkSync(RESPONSE_PATH); } catch {}
|
|
96
|
+
return;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error('[tool-bridge] replay failed:', e);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Write the request and create pending sentinel
|
|
104
|
+
writeFileSync(REQUEST_PATH, JSON.stringify({
|
|
105
|
+
tool_use_id: req.id,
|
|
106
|
+
name: toolName,
|
|
107
|
+
input: toolInput,
|
|
108
|
+
}));
|
|
109
|
+
writeFileSync(PENDING_PATH, '');
|
|
110
|
+
|
|
111
|
+
// Watch for response.json — prefer fs.watch (inotify/kqueue) over
|
|
112
|
+
// fs.watchFile (stat polling). Fall back to watchFile if watch fails.
|
|
113
|
+
let resolved = false;
|
|
114
|
+
const onResponse = () => {
|
|
115
|
+
if (resolved) return;
|
|
116
|
+
if (!existsSync(RESPONSE_PATH)) return;
|
|
117
|
+
resolved = true;
|
|
118
|
+
// Clean up whichever watcher is active
|
|
119
|
+
if (watcher) { try { watcher.close(); } catch {} }
|
|
120
|
+
try { unwatchFile(RESPONSE_PATH, pollFallback); } catch {}
|
|
121
|
+
try {
|
|
122
|
+
const resp = JSON.parse(readFileSync(RESPONSE_PATH, 'utf8'));
|
|
123
|
+
try { unlinkSync(RESPONSE_PATH); } catch {}
|
|
124
|
+
try { unlinkSync(PENDING_PATH); } catch {}
|
|
125
|
+
sendResponse(req.id, {
|
|
126
|
+
content: [{ type: 'text', text: JSON.stringify(resp.content ?? resp) }],
|
|
127
|
+
isError: false,
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
sendError(req.id, -32603, 'Failed to read response: ' + e.message);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const pollFallback = () => onResponse();
|
|
134
|
+
let watcher = null;
|
|
135
|
+
// Check immediately in case it was written between our existsSync check
|
|
136
|
+
onResponse();
|
|
137
|
+
if (resolved) return;
|
|
138
|
+
try {
|
|
139
|
+
watcher = watch(RESPONSE_PATH, () => onResponse());
|
|
140
|
+
watcher.on('error', () => {
|
|
141
|
+
// fs.watch failed mid-watch — fall back to polling
|
|
142
|
+
try { watcher.close(); } catch {}
|
|
143
|
+
watcher = null;
|
|
144
|
+
watchFile(RESPONSE_PATH, { interval: 200 }, pollFallback);
|
|
145
|
+
});
|
|
146
|
+
} catch {
|
|
147
|
+
// fs.watch not available — fall back to stat polling
|
|
148
|
+
watchFile(RESPONSE_PATH, { interval: 200 }, pollFallback);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Unknown method
|
|
153
|
+
if (req.id != null) {
|
|
154
|
+
sendError(req.id, -32601, 'Method not found: ' + req.method);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Read MCP stdio protocol: Content-Length headers + JSON body
|
|
159
|
+
let buffer = '';
|
|
160
|
+
process.stdin.setEncoding('utf8');
|
|
161
|
+
process.stdin.on('data', (chunk) => {
|
|
162
|
+
buffer += chunk;
|
|
163
|
+
while (true) {
|
|
164
|
+
const headerEnd = buffer.indexOf('\\r\\n\\r\\n');
|
|
165
|
+
if (headerEnd === -1) break;
|
|
166
|
+
const header = buffer.slice(0, headerEnd);
|
|
167
|
+
const match = header.match(/Content-Length:\\s*(\\d+)/i);
|
|
168
|
+
if (!match) { buffer = buffer.slice(headerEnd + 4); continue; }
|
|
169
|
+
const len = parseInt(match[1], 10);
|
|
170
|
+
const bodyStart = headerEnd + 4;
|
|
171
|
+
if (buffer.length < bodyStart + len) break;
|
|
172
|
+
const body = buffer.slice(bodyStart, bodyStart + len);
|
|
173
|
+
buffer = buffer.slice(bodyStart + len);
|
|
174
|
+
try {
|
|
175
|
+
handleRequest(JSON.parse(body));
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
process.stdin.on('end', () => process.exit(0));
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build the --mcp-config JSON snippet that adds the tool bridge server
|
|
185
|
+
* alongside any existing MCP servers.
|
|
186
|
+
*/
|
|
187
|
+
export function buildBridgeMcpConfig(
|
|
188
|
+
existingServers: Record<string, unknown>,
|
|
189
|
+
): Record<string, unknown> {
|
|
190
|
+
return {
|
|
191
|
+
...existingServers,
|
|
192
|
+
"tool-bridge": {
|
|
193
|
+
type: "stdio",
|
|
194
|
+
command: "node",
|
|
195
|
+
args: [TOOL_BRIDGE_SCRIPT_PATH],
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Convert CustomTool definitions to the tool bridge's tools.json format.
|
|
202
|
+
*/
|
|
203
|
+
export function toolsToJson(tools: CustomTool[]): string {
|
|
204
|
+
return JSON.stringify(
|
|
205
|
+
tools.map((t) => ({
|
|
206
|
+
name: t.name,
|
|
207
|
+
description: t.description,
|
|
208
|
+
input_schema: t.input_schema,
|
|
209
|
+
})),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateful translator: claude -p stream-json NDJSON → Managed Agents events.
|
|
3
|
+
*
|
|
4
|
+
* The translator is the single source of truth for the Managed Agents event
|
|
5
|
+
* taxonomy. It consumes raw NDJSON objects and returns an array of partially-
|
|
6
|
+
* shaped Managed Agents event payloads for each line. The driver is
|
|
7
|
+
* responsible for wrapping them with `{id, seq, session_id, processed_at}`
|
|
8
|
+
* via `lib/sessions/bus.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Tracked state:
|
|
11
|
+
* - latest `claude_session_id` (from `system.init`) — re-captured every
|
|
12
|
+
* turn, exposed via `getBackendSessionId()`
|
|
13
|
+
* - tool_use_id → classification cache (builtin / mcp / custom) so
|
|
14
|
+
* matching tool_result events route to the right MA type
|
|
15
|
+
* - cumulative usage deltas to apply on `span.model_request_end`
|
|
16
|
+
* - whether this turn involved a custom tool so `stop_reason` becomes
|
|
17
|
+
* `custom_tool_call`
|
|
18
|
+
*
|
|
19
|
+
* Span events are synthesized once per turn by the driver (one start after
|
|
20
|
+
* `session.status_running`, one end before `session.status_idle`). The
|
|
21
|
+
* translator supplies the `model_usage` fields to attach to the end event.
|
|
22
|
+
*/
|
|
23
|
+
import { BUILT_IN_TOOL_NAMES } from "../../types";
|
|
24
|
+
import type {
|
|
25
|
+
ToolClass,
|
|
26
|
+
TranslatedEvent,
|
|
27
|
+
Translator,
|
|
28
|
+
TranslatorOptions,
|
|
29
|
+
TurnResult,
|
|
30
|
+
TurnUsage,
|
|
31
|
+
} from "../shared/translator-types";
|
|
32
|
+
|
|
33
|
+
interface ClaudeContentBlock {
|
|
34
|
+
type: string;
|
|
35
|
+
id?: string;
|
|
36
|
+
text?: string;
|
|
37
|
+
thinking?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
input?: unknown;
|
|
40
|
+
tool_use_id?: string;
|
|
41
|
+
content?: unknown;
|
|
42
|
+
is_error?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ClaudeMessage {
|
|
46
|
+
content?: ClaudeContentBlock[];
|
|
47
|
+
usage?: Partial<TurnUsage> & {
|
|
48
|
+
input_tokens?: number;
|
|
49
|
+
output_tokens?: number;
|
|
50
|
+
cache_read_input_tokens?: number;
|
|
51
|
+
cache_creation_input_tokens?: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const BUILT_IN_SET = new Set<string>(BUILT_IN_TOOL_NAMES);
|
|
56
|
+
|
|
57
|
+
export function createClaudeTranslator(opts: TranslatorOptions): Translator {
|
|
58
|
+
const toolClass = new Map<string, ToolClass>();
|
|
59
|
+
let claudeSessionId: string | null = null;
|
|
60
|
+
let sawInit = false;
|
|
61
|
+
let sawCustom = false;
|
|
62
|
+
let turnResult: TurnResult | null = null;
|
|
63
|
+
|
|
64
|
+
function classify(name: string): ToolClass {
|
|
65
|
+
if (BUILT_IN_SET.has(name)) return "builtin";
|
|
66
|
+
if (name.startsWith("mcp__")) return "mcp";
|
|
67
|
+
if (opts.customToolNames.has(name)) return "custom";
|
|
68
|
+
// Unknown — treat as builtin (safer default for forward-compat)
|
|
69
|
+
return "builtin";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function translate(raw: Record<string, unknown>): TranslatedEvent[] {
|
|
73
|
+
const out: TranslatedEvent[] = [];
|
|
74
|
+
const type = String(raw.type ?? "");
|
|
75
|
+
|
|
76
|
+
if (type === "system") {
|
|
77
|
+
const subtype = raw.subtype as string | undefined;
|
|
78
|
+
if (subtype === "init") {
|
|
79
|
+
if (typeof raw.session_id === "string") {
|
|
80
|
+
claudeSessionId = raw.session_id;
|
|
81
|
+
}
|
|
82
|
+
// Only emit status_running on the first turn of the session — later
|
|
83
|
+
// turns driven by --resume reuse the existing running status that
|
|
84
|
+
// the driver already emitted before spawning exec.
|
|
85
|
+
if (!sawInit && opts.isFirstTurn) {
|
|
86
|
+
sawInit = true;
|
|
87
|
+
// status_running is emitted by the driver, not the translator.
|
|
88
|
+
}
|
|
89
|
+
sawInit = true;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (type === "assistant") {
|
|
95
|
+
const msg = (raw.message as ClaudeMessage | undefined) ?? {};
|
|
96
|
+
const blocks = msg.content ?? [];
|
|
97
|
+
for (const block of blocks) {
|
|
98
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
99
|
+
out.push({
|
|
100
|
+
type: "agent.message",
|
|
101
|
+
payload: {
|
|
102
|
+
content: [{ type: "text", text: block.text }],
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} else if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
106
|
+
out.push({
|
|
107
|
+
type: "agent.thinking",
|
|
108
|
+
payload: {
|
|
109
|
+
content: [{ type: "thinking", thinking: block.thinking }],
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
} else if (block.type === "tool_use" && block.id && block.name) {
|
|
113
|
+
const cls = classify(block.name);
|
|
114
|
+
toolClass.set(block.id, cls);
|
|
115
|
+
if (cls === "custom") {
|
|
116
|
+
sawCustom = true;
|
|
117
|
+
out.push({
|
|
118
|
+
type: "agent.custom_tool_use",
|
|
119
|
+
payload: {
|
|
120
|
+
tool_use_id: block.id,
|
|
121
|
+
name: block.name,
|
|
122
|
+
input: block.input ?? {},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
} else if (cls === "mcp") {
|
|
126
|
+
// name format: mcp__server__tool
|
|
127
|
+
const parts = block.name.split("__");
|
|
128
|
+
const serverName = parts[1] ?? "unknown";
|
|
129
|
+
const toolName = parts.slice(2).join("__") || block.name;
|
|
130
|
+
out.push({
|
|
131
|
+
type: "agent.mcp_tool_use",
|
|
132
|
+
payload: {
|
|
133
|
+
tool_use_id: block.id,
|
|
134
|
+
server_name: serverName,
|
|
135
|
+
tool_name: toolName,
|
|
136
|
+
input: block.input ?? {},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
out.push({
|
|
141
|
+
type: "agent.tool_use",
|
|
142
|
+
payload: {
|
|
143
|
+
tool_use_id: block.id,
|
|
144
|
+
name: block.name,
|
|
145
|
+
input: block.input ?? {},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (type === "user") {
|
|
155
|
+
const msg = (raw.message as ClaudeMessage | undefined) ?? {};
|
|
156
|
+
const blocks = msg.content ?? [];
|
|
157
|
+
for (const block of blocks) {
|
|
158
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
159
|
+
const cls = toolClass.get(block.tool_use_id);
|
|
160
|
+
if (cls === "custom") continue; // custom tool results come from the client
|
|
161
|
+
const eventType = cls === "mcp" ? "agent.mcp_tool_result" : "agent.tool_result";
|
|
162
|
+
out.push({
|
|
163
|
+
type: eventType,
|
|
164
|
+
payload: {
|
|
165
|
+
tool_use_id: block.tool_use_id,
|
|
166
|
+
content: block.content ?? null,
|
|
167
|
+
is_error: block.is_error ?? false,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (type === "result") {
|
|
176
|
+
const subtype = String(raw.subtype ?? "success");
|
|
177
|
+
const usageRaw = (raw.usage as ClaudeMessage["usage"] | undefined) ?? {};
|
|
178
|
+
const usage: TurnUsage = {
|
|
179
|
+
input_tokens: usageRaw.input_tokens ?? 0,
|
|
180
|
+
output_tokens: usageRaw.output_tokens ?? 0,
|
|
181
|
+
cache_read_input_tokens: usageRaw.cache_read_input_tokens ?? 0,
|
|
182
|
+
cache_creation_input_tokens: usageRaw.cache_creation_input_tokens ?? 0,
|
|
183
|
+
cost_usd: (raw.total_cost_usd as number | undefined) ?? 0,
|
|
184
|
+
};
|
|
185
|
+
let stopReason: TurnResult["stopReason"];
|
|
186
|
+
if (sawCustom) stopReason = "custom_tool_call";
|
|
187
|
+
else if (subtype === "error_max_turns") stopReason = "max_turns";
|
|
188
|
+
else if (subtype === "error_during_execution") stopReason = "error";
|
|
189
|
+
else stopReason = "end_turn";
|
|
190
|
+
|
|
191
|
+
turnResult = {
|
|
192
|
+
stopReason,
|
|
193
|
+
usage,
|
|
194
|
+
num_turns: (raw.num_turns as number | undefined) ?? 1,
|
|
195
|
+
};
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Unrecognized — drop silently, translator is forward-compatible.
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
translate,
|
|
205
|
+
getBackendSessionId: () => claudeSessionId,
|
|
206
|
+
getTurnResult: () => turnResult,
|
|
207
|
+
sawCustomToolUse: () => sawCustom,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprite wrapper script for claude.
|
|
3
|
+
*
|
|
4
|
+
* Reads env vars from stdin (one per line) until a blank line, then execs
|
|
5
|
+
* the real `claude` binary with the remaining stdin piped into claude as
|
|
6
|
+
* the prompt. Credentials never hit URLs or disk.
|
|
7
|
+
*
|
|
8
|
+
* Lifted verbatim from
|
|
9
|
+
*
|
|
10
|
+
*/
|
|
11
|
+
import type { ContainerProvider } from "../../providers/types";
|
|
12
|
+
|
|
13
|
+
// Use /tmp/ for wrapper scripts — it exists on all container runtimes
|
|
14
|
+
// (sprites.dev, Docker, Apple Containers). /home/sprite/ is sprites-specific.
|
|
15
|
+
export const CLAUDE_WRAPPER_PATH = "/tmp/.claude-wrapper";
|
|
16
|
+
|
|
17
|
+
const SPRITE_WRAPPER_SCRIPT = [
|
|
18
|
+
"#!/bin/bash",
|
|
19
|
+
'# Install claude CLI if not present',
|
|
20
|
+
'if ! command -v claude &>/dev/null; then npm install -g @anthropic-ai/claude-code 2>/dev/null; fi',
|
|
21
|
+
'# Read env vars from stdin until blank line',
|
|
22
|
+
'while IFS= read -r line; do [ -z "$line" ] && break; export "$line"; done',
|
|
23
|
+
'# If root, drop to non-root user (bypassPermissions requires non-root)',
|
|
24
|
+
'if [ "$(id -u)" = "0" ]; then',
|
|
25
|
+
' id agent &>/dev/null || useradd -m -s /bin/bash agent 2>/dev/null',
|
|
26
|
+
' chown -R agent /tmp/ 2>/dev/null',
|
|
27
|
+
' exec runuser -u agent -- env PATH="$PATH" HOME="/home/agent" \\',
|
|
28
|
+
' ${ANTHROPIC_API_KEY:+ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY"} \\',
|
|
29
|
+
' ${CLAUDE_CODE_OAUTH_TOKEN:+CLAUDE_CODE_OAUTH_TOKEN="$CLAUDE_CODE_OAUTH_TOKEN"} \\',
|
|
30
|
+
' ${OPENAI_API_KEY:+OPENAI_API_KEY="$OPENAI_API_KEY"} \\',
|
|
31
|
+
' ${VAULT_DIR:+VAULT_DIR="$VAULT_DIR"} \\',
|
|
32
|
+
' claude "$@"',
|
|
33
|
+
'fi',
|
|
34
|
+
'exec claude "$@"',
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
export async function installClaudeWrapper(spriteName: string, provider: ContainerProvider): Promise<void> {
|
|
38
|
+
// Quote-escape for embedding inside a single-quoted shell string.
|
|
39
|
+
const escaped = SPRITE_WRAPPER_SCRIPT.replace(/'/g, "'\\''");
|
|
40
|
+
await provider.exec(spriteName, [
|
|
41
|
+
"bash",
|
|
42
|
+
"-c",
|
|
43
|
+
`printf '%s' '${escaped}' > ${CLAUDE_WRAPPER_PATH} && chmod +x ${CLAUDE_WRAPPER_PATH}`,
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the `codex exec` argv for one turn.
|
|
3
|
+
*
|
|
4
|
+
* Ported from
|
|
5
|
+
*
|
|
6
|
+
*
|
|
7
|
+
* Codex-specific constraints:
|
|
8
|
+
* - No --max-turns (codex has no equivalent; silently ignored like opencode)
|
|
9
|
+
* - No --system-prompt flag — system prompt is wrapped into the user prompt
|
|
10
|
+
* text via the shared wrapPromptWithSystem utility
|
|
11
|
+
* - MCP is passed via -c config flags (not env var like opencode)
|
|
12
|
+
* - Trailing `-` reads prompt from stdin (promptViaStdin: true — like claude)
|
|
13
|
+
* - --full-auto + --ask-for-approval never + --dangerously-bypass-approvals-
|
|
14
|
+
* and-sandbox to prevent headless hangs
|
|
15
|
+
*/
|
|
16
|
+
import type { Agent } from "../../types";
|
|
17
|
+
|
|
18
|
+
export interface BuildCodexArgsInput {
|
|
19
|
+
agent: Agent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildCodexArgs(input: BuildCodexArgsInput): string[] {
|
|
23
|
+
// --full-auto alone is sufficient for
|
|
24
|
+
// non-interactive headless execution on v0.118.0. Flags like
|
|
25
|
+
// --ask-for-approval and --dangerously-bypass-approvals-and-sandbox
|
|
26
|
+
// do NOT exist in the current codex Rust CLI.
|
|
27
|
+
const args = [
|
|
28
|
+
"exec",
|
|
29
|
+
"--json",
|
|
30
|
+
"--full-auto",
|
|
31
|
+
"--skip-git-repo-check",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
if (input.agent.model) {
|
|
35
|
+
// Codex expects bare model names (gpt-5.4, gpt-5.4-mini) — NOT the
|
|
36
|
+
// openai/gpt-5.4 format opencode uses.
|
|
37
|
+
args.push("--model", input.agent.model);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MCP config via -c flags (/lib/oc/cli-providers.ts:219-235)
|
|
41
|
+
if (input.agent.mcp_servers) {
|
|
42
|
+
for (const [name, server] of Object.entries(input.agent.mcp_servers)) {
|
|
43
|
+
if (server.type) {
|
|
44
|
+
args.push("-c", `mcp_servers.${name}.type="${server.type}"`);
|
|
45
|
+
}
|
|
46
|
+
if (server.url) {
|
|
47
|
+
args.push("-c", `mcp_servers.${name}.url="${server.url}"`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof server.command === "string") {
|
|
50
|
+
args.push("-c", `mcp_servers.${name}.command="${server.command}"`);
|
|
51
|
+
}
|
|
52
|
+
if (server.args && server.args.length > 0) {
|
|
53
|
+
args.push(
|
|
54
|
+
"-c",
|
|
55
|
+
`mcp_servers.${name}.args=${JSON.stringify(server.args)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (server.headers) {
|
|
59
|
+
for (const [hk, hv] of Object.entries(server.headers)) {
|
|
60
|
+
args.push("-c", `mcp_servers.${name}.http_headers.${hk}="${hv}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Trailing `-` signals stdin prompt
|
|
67
|
+
args.push("-");
|
|
68
|
+
return args;
|
|
69
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth env + create-time validation for the codex backend.
|
|
3
|
+
*
|
|
4
|
+
* Codex accepts both CODEX_API_KEY and OPENAI_API_KEY. We forward both,
|
|
5
|
+
* setting them to the same value (our config.openAiApiKey) for belt-and-
|
|
6
|
+
* braces. Rejects sk-ant-* tokens explicitly
|
|
7
|
+
* (cli-providers.ts:242-244).
|
|
8
|
+
*
|
|
9
|
+
* will verify which env var codex actually prefers on the current
|
|
10
|
+
* v0.118.0 release.
|
|
11
|
+
*/
|
|
12
|
+
import { getConfig } from "../../config";
|
|
13
|
+
|
|
14
|
+
export function buildCodexAuthEnv(): Record<string, string> {
|
|
15
|
+
const cfg = getConfig();
|
|
16
|
+
const env: Record<string, string> = {};
|
|
17
|
+
if (cfg.openAiApiKey) {
|
|
18
|
+
env.OPENAI_API_KEY = cfg.openAiApiKey;
|
|
19
|
+
env.CODEX_API_KEY = cfg.openAiApiKey;
|
|
20
|
+
}
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns null if codex can run, or an error message if it can't. Used at
|
|
26
|
+
* agent create time (validateAgentCreation) and first-turn time
|
|
27
|
+
* (validateRuntime).
|
|
28
|
+
*/
|
|
29
|
+
export function validateCodexRuntime(): string | null {
|
|
30
|
+
const cfg = getConfig();
|
|
31
|
+
if (!cfg.openAiApiKey) {
|
|
32
|
+
return "codex backend requires OPENAI_API_KEY to be set";
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex backend: drives OpenAI's `codex exec` on sprites.dev containers.
|
|
3
|
+
*
|
|
4
|
+
* Ported from
|
|
5
|
+
*
|
|
6
|
+
* (the codex provider), adapted for our sprite-only
|
|
7
|
+
* execution model. Opencompletions only ran codex in its local backend;
|
|
8
|
+
* this adapter is the first time codex runs inside a sprites.dev sprite,
|
|
9
|
+
* so the wrapper script + install flow mirror the opencode adapter's
|
|
10
|
+
* sprite-side patterns.
|
|
11
|
+
*
|
|
12
|
+
* Custom tool re-entry is NOT supported by codex — codex exec has no
|
|
13
|
+
* equivalent of claude's --input-format stream-json. buildTurn rejects
|
|
14
|
+
* toolResults.length > 0 with an invalid_request_error.
|
|
15
|
+
*/
|
|
16
|
+
import { ApiError } from "../../errors";
|
|
17
|
+
import type { Backend, BuildTurnInput, BuildTurnResult } from "../types";
|
|
18
|
+
import type { TranslatorOptions } from "../shared/translator-types";
|
|
19
|
+
import { wrapPromptWithSystem } from "../shared/wrap-prompt";
|
|
20
|
+
import { buildCodexArgs } from "./args";
|
|
21
|
+
import { buildCodexAuthEnv, validateCodexRuntime } from "./auth";
|
|
22
|
+
import { createCodexTranslator } from "./translator";
|
|
23
|
+
import { CODEX_WRAPPER_PATH } from "./wrapper-script";
|
|
24
|
+
import { prepareCodexOnSprite } from "./setup";
|
|
25
|
+
|
|
26
|
+
function buildTurn(input: BuildTurnInput): BuildTurnResult {
|
|
27
|
+
const { agent, promptText, toolResults } = input;
|
|
28
|
+
if (toolResults.length > 0) {
|
|
29
|
+
throw new ApiError(
|
|
30
|
+
400,
|
|
31
|
+
"invalid_request_error",
|
|
32
|
+
"codex backend does not support user.custom_tool_result re-entry in v1",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const argv = buildCodexArgs({ agent });
|
|
36
|
+
const env = buildCodexAuthEnv();
|
|
37
|
+
const wrappedPrompt = wrapPromptWithSystem(promptText, agent.system);
|
|
38
|
+
return { argv, env, stdin: wrappedPrompt };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const codexBackend: Backend = {
|
|
42
|
+
name: "codex",
|
|
43
|
+
wrapperPath: CODEX_WRAPPER_PATH,
|
|
44
|
+
buildTurn,
|
|
45
|
+
createTranslator: (opts: TranslatorOptions) => createCodexTranslator(opts),
|
|
46
|
+
prepareOnSprite: (name, provider) => prepareCodexOnSprite(name, provider),
|
|
47
|
+
|
|
48
|
+
validateRuntime: validateCodexRuntime,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
buildCodexArgs,
|
|
53
|
+
buildCodexAuthEnv,
|
|
54
|
+
createCodexTranslator,
|
|
55
|
+
prepareCodexOnSprite,
|
|
56
|
+
CODEX_WRAPPER_PATH,
|
|
57
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install codex on a freshly-created sprite.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors lib/backends/opencode/setup.ts with the same sentinel + symlink
|
|
5
|
+
* fix pattern Codex is installed via npm from the
|
|
6
|
+
* @openai/codex package.
|
|
7
|
+
*
|
|
8
|
+
*
|
|
9
|
+
*/
|
|
10
|
+
import type { ContainerProvider } from "../../providers/types";
|
|
11
|
+
import { installCodexWrapper } from "./wrapper-script";
|
|
12
|
+
|
|
13
|
+
const SENTINEL_NAME = ".claude-agents-codex-installed";
|
|
14
|
+
|
|
15
|
+
export async function prepareCodexOnSprite(spriteName: string, provider: ContainerProvider): Promise<void> {
|
|
16
|
+
await installCodexWrapper(spriteName, provider);
|
|
17
|
+
|
|
18
|
+
const script = [
|
|
19
|
+
"set -euo pipefail",
|
|
20
|
+
`SENTINEL="$HOME/${SENTINEL_NAME}"`,
|
|
21
|
+
'if [ -f "$SENTINEL" ]; then exit 0; fi',
|
|
22
|
+
"npm install -g @openai/codex",
|
|
23
|
+
"PREFIX=$(npm config get prefix)",
|
|
24
|
+
'if [ "$PREFIX" != "/usr/local" ]; then ln -sf "$PREFIX/bin/codex" /usr/local/bin/codex; fi',
|
|
25
|
+
'/usr/local/bin/codex --version || $PREFIX/bin/codex --version',
|
|
26
|
+
'touch "$SENTINEL"',
|
|
27
|
+
].join(" && ");
|
|
28
|
+
|
|
29
|
+
const result = await provider.exec(spriteName, ["bash", "-c", script], {
|
|
30
|
+
timeoutMs: 5 * 60_000,
|
|
31
|
+
});
|
|
32
|
+
if (result.exit_code !== 0) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`codex install failed (${result.exit_code}): ${result.stderr.slice(0, 500)}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|