@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
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "Apache-2.0",
|
|
3
|
+
"name": "@agentstep/agent-sdk",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./queue": "./src/queue/index.ts",
|
|
12
|
+
"./config": "./src/config/index.ts",
|
|
13
|
+
"./backends/claude": "./src/backends/claude/index.ts",
|
|
14
|
+
"./backends/codex": "./src/backends/codex/index.ts",
|
|
15
|
+
"./backends/opencode": "./src/backends/opencode/index.ts",
|
|
16
|
+
"./handlers": "./src/handlers/index.ts",
|
|
17
|
+
"./*": "./src/*.ts"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/agentstep/gateway.git",
|
|
22
|
+
"directory": "packages/agent-sdk"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
|
34
|
+
"libsql": "^0.5.29",
|
|
35
|
+
"ulid": "^2.3.0",
|
|
36
|
+
"ws": "^8.18.0",
|
|
37
|
+
"zod": "^3.23.8"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
41
|
+
"@types/ws": "^8.5.13",
|
|
42
|
+
"typescript": "^5.9.3",
|
|
43
|
+
"vitest": "^2.1.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication middleware.
|
|
3
|
+
*
|
|
4
|
+
* Extracts an API key from `x-api-key` (preferred per Managed Agents spec)
|
|
5
|
+
* or `Authorization: Bearer <token>`. Hashes with sha256 and looks it up in
|
|
6
|
+
* the `api_keys` table. Returns an AuthContext on success.
|
|
7
|
+
*
|
|
8
|
+
* Simplified rewrite of
|
|
9
|
+
* —
|
|
10
|
+
* no WorkOS, no grace cache, no admin bypass key.
|
|
11
|
+
*/
|
|
12
|
+
import { findByRawKey } from "../db/api_keys";
|
|
13
|
+
import type { AuthContext } from "../types";
|
|
14
|
+
import { unauthorized } from "../errors";
|
|
15
|
+
|
|
16
|
+
export function extractKey(request: Request): string | null {
|
|
17
|
+
const xKey = request.headers.get("x-api-key");
|
|
18
|
+
if (xKey && xKey.length > 0) return xKey;
|
|
19
|
+
|
|
20
|
+
const auth = request.headers.get("authorization") || request.headers.get("Authorization");
|
|
21
|
+
if (!auth) return null;
|
|
22
|
+
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
|
23
|
+
return m ? m[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function authenticate(request: Request): Promise<AuthContext> {
|
|
27
|
+
const key = extractKey(request);
|
|
28
|
+
if (!key) throw unauthorized();
|
|
29
|
+
|
|
30
|
+
const row = findByRawKey(key);
|
|
31
|
+
if (!row) throw unauthorized();
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
keyId: row.id,
|
|
35
|
+
name: row.name,
|
|
36
|
+
permissions: JSON.parse(row.permissions_json) as string[],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the `claude -p` argv for one turn.
|
|
3
|
+
*
|
|
4
|
+
* Collapses multi-CLI
|
|
5
|
+
*
|
|
6
|
+
* down to the claude-only shape this service needs.
|
|
7
|
+
*
|
|
8
|
+
* Always emits:
|
|
9
|
+
* -p --output-format stream-json --verbose
|
|
10
|
+
* --permission-mode bypassPermissions --max-turns <N>
|
|
11
|
+
*
|
|
12
|
+
* Adds `--resume <claude_session_id>` on every turn ≥ 2 using the most
|
|
13
|
+
* recently observed id from the previous turn's `system.init`.
|
|
14
|
+
*/
|
|
15
|
+
import { getConfig } from "../../config";
|
|
16
|
+
import type { Agent, McpServerConfig } from "../../types";
|
|
17
|
+
import { resolveToolset } from "../../sessions/tools";
|
|
18
|
+
|
|
19
|
+
export interface BuildArgsInput {
|
|
20
|
+
agent: Agent;
|
|
21
|
+
claudeSessionId?: string | null;
|
|
22
|
+
maxTurns?: number;
|
|
23
|
+
confirmationMode?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildClaudeArgs(input: BuildArgsInput): string[] {
|
|
27
|
+
const cfg = getConfig();
|
|
28
|
+
const permissionMode = input.confirmationMode ? "default" : "bypassPermissions";
|
|
29
|
+
const argv: string[] = [
|
|
30
|
+
"-p",
|
|
31
|
+
"--output-format",
|
|
32
|
+
"stream-json",
|
|
33
|
+
"--verbose",
|
|
34
|
+
"--permission-mode",
|
|
35
|
+
permissionMode,
|
|
36
|
+
"--max-turns",
|
|
37
|
+
String(input.maxTurns ?? cfg.agentMaxTurns),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
if (input.claudeSessionId) {
|
|
41
|
+
argv.push("--resume", input.claudeSessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (input.agent.system) {
|
|
45
|
+
argv.push("--system-prompt", input.agent.system);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (input.agent.model) {
|
|
49
|
+
argv.push("--model", input.agent.model);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tools = resolveToolset(input.agent.tools);
|
|
53
|
+
if (tools.allowedTools.length) {
|
|
54
|
+
argv.push("--allowed-tools", tools.allowedTools.join(","));
|
|
55
|
+
}
|
|
56
|
+
if (tools.disallowedTools.length) {
|
|
57
|
+
argv.push("--disallowed-tools", tools.disallowedTools.join(","));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (input.agent.mcp_servers && Object.keys(input.agent.mcp_servers).length > 0) {
|
|
61
|
+
argv.push(
|
|
62
|
+
"--mcp-config",
|
|
63
|
+
JSON.stringify({ mcpServers: input.agent.mcp_servers satisfies Record<string, McpServerConfig> }),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return argv;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Return the auth env vars for claude as a key-value map. The driver
|
|
72
|
+
* composes these into the wrapper stdin as `KEY=value` lines.
|
|
73
|
+
*/
|
|
74
|
+
export function buildClaudeAuthEnv(): Record<string, string> {
|
|
75
|
+
const cfg = getConfig();
|
|
76
|
+
const env: Record<string, string> = {};
|
|
77
|
+
|
|
78
|
+
const token = cfg.anthropicApiKey || cfg.claudeToken;
|
|
79
|
+
if (token) {
|
|
80
|
+
if (token.startsWith("sk-ant-oat")) {
|
|
81
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
82
|
+
} else {
|
|
83
|
+
env.ANTHROPIC_API_KEY = token;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return env;
|
|
88
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude backend: drives `claude -p` on sprites.dev containers.
|
|
3
|
+
*
|
|
4
|
+
* Implements the Backend interface over the existing claude-specific
|
|
5
|
+
* args/translator/wrapper modules. `buildTurn` owns the stream-json
|
|
6
|
+
* tool_result re-entry path — if `toolResults` is non-empty, it builds a
|
|
7
|
+
* `{type: "user", message: {role, content: [tool_result, ...]}}` frame and
|
|
8
|
+
* flips the argv to include `--input-format stream-json`.
|
|
9
|
+
*/
|
|
10
|
+
import { ApiError } from "../../errors";
|
|
11
|
+
import { getConfig } from "../../config";
|
|
12
|
+
import type { CustomTool } from "../../types";
|
|
13
|
+
import type { ContainerProvider } from "../../providers/types";
|
|
14
|
+
import type { Backend, BuildTurnInput, BuildTurnResult } from "../types";
|
|
15
|
+
import type { TranslatorOptions } from "../shared/translator-types";
|
|
16
|
+
import { buildClaudeArgs, buildClaudeAuthEnv } from "./args";
|
|
17
|
+
import { createClaudeTranslator } from "./translator";
|
|
18
|
+
import { CLAUDE_WRAPPER_PATH, installClaudeWrapper } from "./wrapper-script";
|
|
19
|
+
import {
|
|
20
|
+
generateBridgeScript,
|
|
21
|
+
buildBridgeMcpConfig,
|
|
22
|
+
toolsToJson,
|
|
23
|
+
TOOL_BRIDGE_DIR,
|
|
24
|
+
TOOL_BRIDGE_SCRIPT_PATH,
|
|
25
|
+
TOOL_BRIDGE_TOOLS_PATH,
|
|
26
|
+
} from "./tool-bridge";
|
|
27
|
+
import {
|
|
28
|
+
generatePermissionHookScript,
|
|
29
|
+
buildPermissionHooksConfig,
|
|
30
|
+
PERMISSION_BRIDGE_DIR,
|
|
31
|
+
PERMISSION_HOOK_SCRIPT_PATH,
|
|
32
|
+
} from "./permission-hook";
|
|
33
|
+
|
|
34
|
+
function buildTurn(input: BuildTurnInput): BuildTurnResult {
|
|
35
|
+
const { agent, backendSessionId, promptText, toolResults } = input;
|
|
36
|
+
|
|
37
|
+
const argsBase = buildClaudeArgs({
|
|
38
|
+
agent,
|
|
39
|
+
claudeSessionId: backendSessionId,
|
|
40
|
+
confirmationMode: agent.confirmation_mode,
|
|
41
|
+
});
|
|
42
|
+
const env = buildClaudeAuthEnv();
|
|
43
|
+
|
|
44
|
+
// If the agent has custom tools or threads_enabled (which adds spawn_agent
|
|
45
|
+
// as a bridge tool), inject the tool bridge MCP server into --mcp-config.
|
|
46
|
+
// The bridge script + tools.json are installed on the sprite by prepareOnSprite.
|
|
47
|
+
// This overrides any existing --mcp-config in argsBase — we find it and merge.
|
|
48
|
+
const customTools = agent.tools.filter((t): t is CustomTool => t.type === "custom");
|
|
49
|
+
const hasBridgeTools = customTools.length > 0 || agent.threads_enabled;
|
|
50
|
+
if (hasBridgeTools) {
|
|
51
|
+
const mcpIdx = argsBase.indexOf("--mcp-config");
|
|
52
|
+
let existingServers: Record<string, unknown> = {};
|
|
53
|
+
if (mcpIdx >= 0 && mcpIdx + 1 < argsBase.length) {
|
|
54
|
+
try {
|
|
55
|
+
const existing = JSON.parse(argsBase[mcpIdx + 1]) as { mcpServers?: Record<string, unknown> };
|
|
56
|
+
existingServers = existing.mcpServers ?? {};
|
|
57
|
+
} catch {}
|
|
58
|
+
// Remove the old --mcp-config pair
|
|
59
|
+
argsBase.splice(mcpIdx, 2);
|
|
60
|
+
}
|
|
61
|
+
const merged = buildBridgeMcpConfig(existingServers);
|
|
62
|
+
argsBase.push("--mcp-config", JSON.stringify({ mcpServers: merged }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (toolResults.length > 0) {
|
|
66
|
+
// Stream-json re-entry: claude accepts a user frame on stdin that mixes
|
|
67
|
+
// text and tool_result content blocks. Spike S5 verified this works with
|
|
68
|
+
// --resume + --input-format stream-json.
|
|
69
|
+
const args = [...argsBase, "--input-format", "stream-json"];
|
|
70
|
+
const content: Array<Record<string, unknown>> = [];
|
|
71
|
+
if (promptText) {
|
|
72
|
+
content.push({ type: "text", text: promptText });
|
|
73
|
+
}
|
|
74
|
+
for (const r of toolResults) {
|
|
75
|
+
content.push({
|
|
76
|
+
type: "tool_result",
|
|
77
|
+
tool_use_id: r.custom_tool_use_id,
|
|
78
|
+
content: r.content,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const userFrame = JSON.stringify({
|
|
82
|
+
type: "user",
|
|
83
|
+
message: { role: "user", content },
|
|
84
|
+
});
|
|
85
|
+
return { argv: args, env, stdin: userFrame };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { argv: argsBase, env, stdin: promptText };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateRuntime(): string | null {
|
|
92
|
+
const cfg = getConfig();
|
|
93
|
+
if (!cfg.anthropicApiKey && !cfg.claudeToken) {
|
|
94
|
+
return "claude backend requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN to be set";
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Install the bridge script and tools.json on the sprite if the agent has
|
|
101
|
+
* custom tools. Called from the lifecycle after the base prepareOnSprite.
|
|
102
|
+
*/
|
|
103
|
+
async function installToolBridge(
|
|
104
|
+
spriteName: string,
|
|
105
|
+
customTools: CustomTool[],
|
|
106
|
+
provider: ContainerProvider,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
if (customTools.length === 0) return;
|
|
109
|
+
|
|
110
|
+
await provider.exec(spriteName, ["mkdir", "-p", TOOL_BRIDGE_DIR]);
|
|
111
|
+
await provider.exec(
|
|
112
|
+
spriteName,
|
|
113
|
+
["bash", "-c", `cat > ${TOOL_BRIDGE_SCRIPT_PATH}`],
|
|
114
|
+
{ stdin: generateBridgeScript() },
|
|
115
|
+
);
|
|
116
|
+
await provider.exec(
|
|
117
|
+
spriteName,
|
|
118
|
+
["bash", "-c", `cat > ${TOOL_BRIDGE_TOOLS_PATH}`],
|
|
119
|
+
{ stdin: toolsToJson(customTools) },
|
|
120
|
+
);
|
|
121
|
+
await provider.exec(spriteName, ["chmod", "+x", TOOL_BRIDGE_SCRIPT_PATH]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Install the permission hook script and configure Claude Code's settings
|
|
126
|
+
* to use it. Called from the lifecycle after prepareOnSprite when the agent
|
|
127
|
+
* has confirmation_mode enabled.
|
|
128
|
+
*/
|
|
129
|
+
async function installPermissionHook(
|
|
130
|
+
spriteName: string,
|
|
131
|
+
provider: ContainerProvider,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
// Create the bridge directory
|
|
134
|
+
await provider.exec(spriteName, ["mkdir", "-p", PERMISSION_BRIDGE_DIR]);
|
|
135
|
+
|
|
136
|
+
// Write the hook script
|
|
137
|
+
await provider.exec(
|
|
138
|
+
spriteName,
|
|
139
|
+
["bash", "-c", `cat > ${PERMISSION_HOOK_SCRIPT_PATH}`],
|
|
140
|
+
{ stdin: generatePermissionHookScript() },
|
|
141
|
+
);
|
|
142
|
+
await provider.exec(spriteName, ["chmod", "+x", PERMISSION_HOOK_SCRIPT_PATH]);
|
|
143
|
+
|
|
144
|
+
// Write the hooks config to $HOME/.claude/settings.json.
|
|
145
|
+
// Claude Code reads hooks from the user's settings file at startup.
|
|
146
|
+
// We need to merge with any existing settings (e.g. from prior setup).
|
|
147
|
+
const hooksConfig = buildPermissionHooksConfig();
|
|
148
|
+
const settingsPath = "/home/sprite/.claude/settings.json";
|
|
149
|
+
|
|
150
|
+
// Read existing settings if any, merge hooks config
|
|
151
|
+
let existingSettings: Record<string, unknown> = {};
|
|
152
|
+
try {
|
|
153
|
+
const result = await provider.exec(
|
|
154
|
+
spriteName,
|
|
155
|
+
["cat", settingsPath],
|
|
156
|
+
);
|
|
157
|
+
if (result.stdout.trim()) {
|
|
158
|
+
existingSettings = JSON.parse(result.stdout) as Record<string, unknown>;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// No existing settings — that's fine
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const merged = { ...existingSettings, ...hooksConfig };
|
|
165
|
+
await provider.exec(
|
|
166
|
+
spriteName,
|
|
167
|
+
["bash", "-c", `mkdir -p /home/sprite/.claude && cat > ${settingsPath}`],
|
|
168
|
+
{ stdin: JSON.stringify(merged, null, 2) },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const claudeBackend: Backend = {
|
|
173
|
+
name: "claude",
|
|
174
|
+
wrapperPath: CLAUDE_WRAPPER_PATH,
|
|
175
|
+
buildTurn,
|
|
176
|
+
createTranslator: (opts: TranslatorOptions) => createClaudeTranslator(opts),
|
|
177
|
+
prepareOnSprite: (name: string, provider: ContainerProvider) => installClaudeWrapper(name, provider),
|
|
178
|
+
validateRuntime,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Re-export utilities needed by tests or other modules that historically
|
|
182
|
+
// imported them from lib/claude/*.
|
|
183
|
+
export {
|
|
184
|
+
buildClaudeArgs,
|
|
185
|
+
buildClaudeAuthEnv,
|
|
186
|
+
createClaudeTranslator,
|
|
187
|
+
installClaudeWrapper,
|
|
188
|
+
installToolBridge,
|
|
189
|
+
installPermissionHook,
|
|
190
|
+
CLAUDE_WRAPPER_PATH,
|
|
191
|
+
};
|
|
192
|
+
// Re-export ApiError so the driver doesn't need to handle this indirection
|
|
193
|
+
export { ApiError };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission hook bridge: generates a Node.js hook script that bridges
|
|
3
|
+
* Claude Code's PermissionRequest hook to the Managed Agents API's
|
|
4
|
+
* `user.tool_confirmation` flow.
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* - A Node.js script installed at /tmp/permission-bridge/hook.mjs
|
|
8
|
+
* - Claude Code fires PermissionRequest hooks when running in
|
|
9
|
+
* `--permission-mode default` and a tool needs approval
|
|
10
|
+
* - The hook receives the permission request JSON on stdin
|
|
11
|
+
* - It writes /tmp/permission-bridge/request.json with tool details
|
|
12
|
+
* - Creates /tmp/permission-bridge/pending sentinel
|
|
13
|
+
* - Blocks polling for /tmp/permission-bridge/response.json via fs.watchFile
|
|
14
|
+
* - On response: outputs the hook result JSON to stdout and cleans up
|
|
15
|
+
*
|
|
16
|
+
* The driver detects the pending sentinel via a background poller during
|
|
17
|
+
* the stream loop. When found, it emits `agent.tool_confirmation_request`
|
|
18
|
+
* on the event bus and waits for the client to POST `user.tool_confirmation`.
|
|
19
|
+
* The events route writes response.json into the container, unblocking the hook.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const PERMISSION_BRIDGE_DIR = "/tmp/permission-bridge";
|
|
23
|
+
export const PERMISSION_HOOK_SCRIPT_PATH = `${PERMISSION_BRIDGE_DIR}/hook.mjs`;
|
|
24
|
+
export const PERMISSION_BRIDGE_REQUEST_PATH = `${PERMISSION_BRIDGE_DIR}/request.json`;
|
|
25
|
+
export const PERMISSION_BRIDGE_RESPONSE_PATH = `${PERMISSION_BRIDGE_DIR}/response.json`;
|
|
26
|
+
export const PERMISSION_BRIDGE_PENDING_PATH = `${PERMISSION_BRIDGE_DIR}/pending`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate the PermissionRequest hook script as a string.
|
|
30
|
+
* This script is written to the container and referenced from
|
|
31
|
+
* $HOME/.claude/settings.json hooks config.
|
|
32
|
+
*/
|
|
33
|
+
export function generatePermissionHookScript(): string {
|
|
34
|
+
return `#!/usr/bin/env node
|
|
35
|
+
// Auto-generated PermissionRequest hook for tool confirmation bridge.
|
|
36
|
+
// Reads hook JSON from stdin, writes request.json + pending sentinel,
|
|
37
|
+
// polls for response.json, then outputs the hook response to stdout.
|
|
38
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, watchFile, unwatchFile } from 'node:fs';
|
|
39
|
+
|
|
40
|
+
const REQUEST_PATH = ${JSON.stringify(PERMISSION_BRIDGE_REQUEST_PATH)};
|
|
41
|
+
const RESPONSE_PATH = ${JSON.stringify(PERMISSION_BRIDGE_RESPONSE_PATH)};
|
|
42
|
+
const PENDING_PATH = ${JSON.stringify(PERMISSION_BRIDGE_PENDING_PATH)};
|
|
43
|
+
const TIMEOUT_MS = 120000; // 2 minutes
|
|
44
|
+
|
|
45
|
+
// Read hook input from stdin
|
|
46
|
+
let input = '';
|
|
47
|
+
process.stdin.setEncoding('utf8');
|
|
48
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
49
|
+
process.stdin.on('end', () => {
|
|
50
|
+
let hookInput;
|
|
51
|
+
try {
|
|
52
|
+
hookInput = JSON.parse(input);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// If we can't parse the input, allow by default to avoid blocking
|
|
55
|
+
console.error('[permission-hook] failed to parse stdin:', e.message);
|
|
56
|
+
outputResult({ behavior: 'allow' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Extract tool details from the PermissionRequest hook payload.
|
|
61
|
+
// Claude Code sends: { tool_name, tool_input, tool_use_id, ... }
|
|
62
|
+
const toolName = hookInput.tool_name || hookInput.toolName || 'unknown';
|
|
63
|
+
const toolInput = hookInput.tool_input || hookInput.toolInput || {};
|
|
64
|
+
const toolUseId = hookInput.tool_use_id || hookInput.toolUseId || '';
|
|
65
|
+
|
|
66
|
+
// Write request.json with tool details
|
|
67
|
+
writeFileSync(REQUEST_PATH, JSON.stringify({
|
|
68
|
+
tool_name: toolName,
|
|
69
|
+
tool_input: toolInput,
|
|
70
|
+
tool_use_id: toolUseId,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// Create pending sentinel
|
|
74
|
+
writeFileSync(PENDING_PATH, '');
|
|
75
|
+
|
|
76
|
+
// Poll for response.json
|
|
77
|
+
const startTime = Date.now();
|
|
78
|
+
let resolved = false;
|
|
79
|
+
|
|
80
|
+
const checkResponse = () => {
|
|
81
|
+
if (resolved) return;
|
|
82
|
+
if (!existsSync(RESPONSE_PATH)) return;
|
|
83
|
+
resolved = true;
|
|
84
|
+
try { unwatchFile(RESPONSE_PATH, pollFn); } catch {}
|
|
85
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const resp = JSON.parse(readFileSync(RESPONSE_PATH, 'utf8'));
|
|
89
|
+
try { unlinkSync(RESPONSE_PATH); } catch {}
|
|
90
|
+
try { unlinkSync(PENDING_PATH); } catch {}
|
|
91
|
+
|
|
92
|
+
if (resp.result === 'allow') {
|
|
93
|
+
outputResult({ behavior: 'allow' });
|
|
94
|
+
} else {
|
|
95
|
+
outputResult({ behavior: 'deny', message: resp.deny_message || 'User denied tool use' });
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error('[permission-hook] failed to read response:', e.message);
|
|
99
|
+
// On error, deny to be safe
|
|
100
|
+
try { unlinkSync(PENDING_PATH); } catch {}
|
|
101
|
+
outputResult({ behavior: 'deny', message: 'Permission hook error: ' + e.message });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const pollFn = () => checkResponse();
|
|
106
|
+
|
|
107
|
+
// Check immediately in case response was pre-written
|
|
108
|
+
checkResponse();
|
|
109
|
+
if (resolved) return;
|
|
110
|
+
|
|
111
|
+
// Use fs.watchFile for reliable polling
|
|
112
|
+
watchFile(RESPONSE_PATH, { interval: 200 }, pollFn);
|
|
113
|
+
|
|
114
|
+
// Timeout: deny after TIMEOUT_MS
|
|
115
|
+
const timeoutTimer = setTimeout(() => {
|
|
116
|
+
if (resolved) return;
|
|
117
|
+
resolved = true;
|
|
118
|
+
try { unwatchFile(RESPONSE_PATH, pollFn); } catch {}
|
|
119
|
+
try { unlinkSync(PENDING_PATH); } catch {}
|
|
120
|
+
outputResult({ behavior: 'deny', message: 'Permission request timed out after ' + (TIMEOUT_MS / 1000) + 's' });
|
|
121
|
+
}, TIMEOUT_MS);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function outputResult(decision) {
|
|
125
|
+
const output = JSON.stringify({
|
|
126
|
+
hookSpecificOutput: {
|
|
127
|
+
hookEventName: 'PermissionRequest',
|
|
128
|
+
decision: decision,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
process.stdout.write(output);
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the settings.json hooks configuration for the permission hook.
|
|
139
|
+
* Returns the JSON object to merge into $HOME/.claude/settings.json.
|
|
140
|
+
*/
|
|
141
|
+
export function buildPermissionHooksConfig(): Record<string, unknown> {
|
|
142
|
+
return {
|
|
143
|
+
hooks: {
|
|
144
|
+
PermissionRequest: [
|
|
145
|
+
{
|
|
146
|
+
type: "command",
|
|
147
|
+
command: `node ${PERMISSION_HOOK_SCRIPT_PATH}`,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|