@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.
Files changed (105) hide show
  1. package/package.json +45 -0
  2. package/src/auth/middleware.ts +38 -0
  3. package/src/backends/claude/args.ts +88 -0
  4. package/src/backends/claude/index.ts +193 -0
  5. package/src/backends/claude/permission-hook.ts +152 -0
  6. package/src/backends/claude/tool-bridge.ts +211 -0
  7. package/src/backends/claude/translator.ts +209 -0
  8. package/src/backends/claude/wrapper-script.ts +45 -0
  9. package/src/backends/codex/args.ts +69 -0
  10. package/src/backends/codex/auth.ts +35 -0
  11. package/src/backends/codex/index.ts +57 -0
  12. package/src/backends/codex/setup.ts +37 -0
  13. package/src/backends/codex/translator.ts +223 -0
  14. package/src/backends/codex/wrapper-script.ts +26 -0
  15. package/src/backends/factory/args.ts +45 -0
  16. package/src/backends/factory/auth.ts +30 -0
  17. package/src/backends/factory/index.ts +56 -0
  18. package/src/backends/factory/setup.ts +34 -0
  19. package/src/backends/factory/translator.ts +139 -0
  20. package/src/backends/factory/wrapper-script.ts +33 -0
  21. package/src/backends/gemini/args.ts +44 -0
  22. package/src/backends/gemini/auth.ts +30 -0
  23. package/src/backends/gemini/index.ts +53 -0
  24. package/src/backends/gemini/setup.ts +34 -0
  25. package/src/backends/gemini/translator.ts +139 -0
  26. package/src/backends/gemini/wrapper-script.ts +26 -0
  27. package/src/backends/opencode/args.ts +53 -0
  28. package/src/backends/opencode/auth.ts +53 -0
  29. package/src/backends/opencode/index.ts +70 -0
  30. package/src/backends/opencode/mcp.ts +67 -0
  31. package/src/backends/opencode/setup.ts +54 -0
  32. package/src/backends/opencode/translator.ts +168 -0
  33. package/src/backends/opencode/wrapper-script.ts +46 -0
  34. package/src/backends/registry.ts +38 -0
  35. package/src/backends/shared/ndjson.ts +29 -0
  36. package/src/backends/shared/translator-types.ts +69 -0
  37. package/src/backends/shared/wrap-prompt.ts +17 -0
  38. package/src/backends/types.ts +85 -0
  39. package/src/config/index.ts +95 -0
  40. package/src/db/agents.ts +185 -0
  41. package/src/db/api_keys.ts +78 -0
  42. package/src/db/batch.ts +142 -0
  43. package/src/db/client.ts +81 -0
  44. package/src/db/environments.ts +127 -0
  45. package/src/db/events.ts +208 -0
  46. package/src/db/memory.ts +143 -0
  47. package/src/db/migrations.ts +295 -0
  48. package/src/db/proxy.ts +37 -0
  49. package/src/db/sessions.ts +295 -0
  50. package/src/db/vaults.ts +110 -0
  51. package/src/errors.ts +53 -0
  52. package/src/handlers/agents.ts +194 -0
  53. package/src/handlers/batch.ts +41 -0
  54. package/src/handlers/docs.ts +87 -0
  55. package/src/handlers/environments.ts +154 -0
  56. package/src/handlers/events.ts +234 -0
  57. package/src/handlers/index.ts +12 -0
  58. package/src/handlers/memory.ts +141 -0
  59. package/src/handlers/openapi.ts +14 -0
  60. package/src/handlers/sessions.ts +223 -0
  61. package/src/handlers/stream.ts +76 -0
  62. package/src/handlers/threads.ts +26 -0
  63. package/src/handlers/ui/app.js +984 -0
  64. package/src/handlers/ui/index.html +112 -0
  65. package/src/handlers/ui/style.css +164 -0
  66. package/src/handlers/ui.ts +1281 -0
  67. package/src/handlers/vaults.ts +99 -0
  68. package/src/http.ts +35 -0
  69. package/src/index.ts +104 -0
  70. package/src/init.ts +227 -0
  71. package/src/openapi/registry.ts +8 -0
  72. package/src/openapi/schemas.ts +625 -0
  73. package/src/openapi/spec.ts +691 -0
  74. package/src/providers/apple.ts +220 -0
  75. package/src/providers/daytona.ts +217 -0
  76. package/src/providers/docker.ts +264 -0
  77. package/src/providers/e2b.ts +203 -0
  78. package/src/providers/fly.ts +276 -0
  79. package/src/providers/modal.ts +222 -0
  80. package/src/providers/podman.ts +206 -0
  81. package/src/providers/registry.ts +28 -0
  82. package/src/providers/shared.ts +11 -0
  83. package/src/providers/sprites.ts +55 -0
  84. package/src/providers/types.ts +73 -0
  85. package/src/providers/vercel.ts +208 -0
  86. package/src/proxy/forward.ts +111 -0
  87. package/src/queue/index.ts +111 -0
  88. package/src/sessions/actor.ts +53 -0
  89. package/src/sessions/bus.ts +155 -0
  90. package/src/sessions/driver.ts +818 -0
  91. package/src/sessions/grader.ts +120 -0
  92. package/src/sessions/interrupt.ts +14 -0
  93. package/src/sessions/sweeper.ts +136 -0
  94. package/src/sessions/threads.ts +126 -0
  95. package/src/sessions/tools.ts +50 -0
  96. package/src/shutdown.ts +78 -0
  97. package/src/sprite/client.ts +294 -0
  98. package/src/sprite/exec.ts +161 -0
  99. package/src/sprite/lifecycle.ts +339 -0
  100. package/src/sprite/pool.ts +65 -0
  101. package/src/sprite/setup.ts +159 -0
  102. package/src/state.ts +61 -0
  103. package/src/types.ts +339 -0
  104. package/src/util/clock.ts +7 -0
  105. 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
+ }