claude-sdk-proxy 2.3.2 → 3.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 +1 -1
- package/src/logger.ts +127 -8
- package/src/mcpTools.ts +3 -207
- package/src/proxy/server.ts +1001 -225
- package/src/proxy/types.ts +9 -2
- package/src/session-store.ts +198 -0
- package/src/trace.ts +633 -0
package/package.json
CHANGED
package/src/logger.ts
CHANGED
|
@@ -1,13 +1,132 @@
|
|
|
1
|
-
|
|
1
|
+
import { mkdirSync, appendFileSync, existsSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
|
|
4
|
+
// ── Configuration ────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const LOG_DIR = process.env.CLAUDE_PROXY_LOG_DIR ?? "/tmp/claude-proxy"
|
|
7
|
+
const LOG_LEVEL_ENV = (process.env.CLAUDE_PROXY_LOG_LEVEL ?? "info").toLowerCase()
|
|
8
|
+
const IS_DEBUG =
|
|
2
9
|
process.env["CLAUDE_PROXY_DEBUG"] === "1" ||
|
|
3
10
|
process.env["OPENCODE_CLAUDE_PROVIDER_DEBUG"] === "1"
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const
|
|
13
|
+
type LogLevel = keyof typeof LEVELS
|
|
14
|
+
|
|
15
|
+
const currentLevel: number = IS_DEBUG
|
|
16
|
+
? LEVELS.debug
|
|
17
|
+
: LEVELS[LOG_LEVEL_ENV as LogLevel] ?? LEVELS.info
|
|
18
|
+
|
|
19
|
+
// ── File output ──────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
let currentDateStr = ""
|
|
22
|
+
let currentLogPath = ""
|
|
23
|
+
|
|
24
|
+
function ensureLogDir() {
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
|
|
27
|
+
const errDir = join(LOG_DIR, "errors")
|
|
28
|
+
if (!existsSync(errDir)) mkdirSync(errDir, { recursive: true })
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ensureLogDir()
|
|
33
|
+
|
|
34
|
+
function getLogPath(): string {
|
|
35
|
+
const dateStr = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
|
36
|
+
if (dateStr !== currentDateStr) {
|
|
37
|
+
currentDateStr = dateStr
|
|
38
|
+
currentLogPath = join(LOG_DIR, `proxy-${dateStr}.log`)
|
|
11
39
|
}
|
|
12
|
-
|
|
40
|
+
return currentLogPath
|
|
13
41
|
}
|
|
42
|
+
|
|
43
|
+
function writeToFile(line: string) {
|
|
44
|
+
try {
|
|
45
|
+
appendFileSync(getLogPath(), line + "\n")
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Structured JSON logging ──────────────────────────────────────────────────
|
|
50
|
+
// Every log line is a single JSON object — parseable by jq, greppable by reqId.
|
|
51
|
+
//
|
|
52
|
+
// Example:
|
|
53
|
+
// {"ts":"2026-02-23T15:30:00.000Z","level":"info","event":"proxy.request","reqId":"req_abc","model":"haiku"}
|
|
54
|
+
// {"ts":"2026-02-23T15:30:05.000Z","level":"error","event":"proxy.sdk.error","reqId":"req_abc","error":"timeout"}
|
|
55
|
+
|
|
56
|
+
export interface LogEntry {
|
|
57
|
+
ts: string
|
|
58
|
+
level: LogLevel
|
|
59
|
+
event: string
|
|
60
|
+
reqId?: string
|
|
61
|
+
[key: string]: unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function emit(level: LogLevel, event: string, data?: Record<string, unknown>) {
|
|
65
|
+
if (LEVELS[level] > currentLevel) return
|
|
66
|
+
|
|
67
|
+
const entry: LogEntry = {
|
|
68
|
+
ts: new Date().toISOString(),
|
|
69
|
+
level,
|
|
70
|
+
event,
|
|
71
|
+
...data,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const line = JSON.stringify(entry)
|
|
75
|
+
|
|
76
|
+
// Always write to stderr (captured by journalctl)
|
|
77
|
+
console.error(line)
|
|
78
|
+
|
|
79
|
+
// Always write to file (persists for post-mortem)
|
|
80
|
+
writeToFile(line)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** Log an error — always emitted. Use for failures, crashes, unexpected states. */
|
|
86
|
+
export function logError(event: string, data?: Record<string, unknown>) {
|
|
87
|
+
emit("error", event, data)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Log a warning — always emitted. Use for degraded states, retries, slow operations. */
|
|
91
|
+
export function logWarn(event: string, data?: Record<string, unknown>) {
|
|
92
|
+
emit("warn", event, data)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Log info — always emitted. Use for request lifecycle events. */
|
|
96
|
+
export function logInfo(event: string, data?: Record<string, unknown>) {
|
|
97
|
+
emit("info", event, data)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Log debug — only when CLAUDE_PROXY_DEBUG=1. Use for verbose details. */
|
|
101
|
+
export function logDebug(event: string, data?: Record<string, unknown>) {
|
|
102
|
+
emit("debug", event, data)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Write an error dump file for a specific request. Returns the file path. */
|
|
106
|
+
export function dumpError(reqId: string, data: Record<string, unknown>): string {
|
|
107
|
+
const errDir = join(LOG_DIR, "errors")
|
|
108
|
+
const path = join(errDir, `${reqId}.json`)
|
|
109
|
+
try {
|
|
110
|
+
if (!existsSync(errDir)) mkdirSync(errDir, { recursive: true })
|
|
111
|
+
const content = JSON.stringify({ ts: new Date().toISOString(), reqId, ...data }, null, 2)
|
|
112
|
+
appendFileSync(path, content)
|
|
113
|
+
} catch (e) {
|
|
114
|
+
logError("logger.dump_failed", { reqId, path, error: String(e) })
|
|
115
|
+
}
|
|
116
|
+
return path
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Legacy API (backward-compatible) ─────────────────────────────────────────
|
|
120
|
+
// These are used by existing code. They map to the new structured logging.
|
|
121
|
+
|
|
122
|
+
/** @deprecated Use logInfo instead */
|
|
123
|
+
export const claudeLog = (message: string, extra?: Record<string, unknown>) => {
|
|
124
|
+
logInfo(message, extra)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @deprecated Use logDebug instead */
|
|
128
|
+
export const claudeDebug = (message: string, extra?: Record<string, unknown>) => {
|
|
129
|
+
logDebug(message, extra)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { LOG_DIR }
|
package/src/mcpTools.ts
CHANGED
|
@@ -1,207 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { readFileSync } from "node:fs"
|
|
5
|
-
import { homedir } from "node:os"
|
|
6
|
-
|
|
7
|
-
// ── Gateway helpers ──────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
function b64urlEncode(buf: Buffer): string {
|
|
10
|
-
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function signPayload(privateKeyPem: string, payload: string): string {
|
|
14
|
-
const key = createPrivateKey(privateKeyPem)
|
|
15
|
-
return b64urlEncode(sign(null, Buffer.from(payload, "utf8"), key))
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function pubKeyRawB64url(publicKeyPem: string): string {
|
|
19
|
-
const pubKey = createPublicKey(publicKeyPem)
|
|
20
|
-
const der = pubKey.export({ type: "spki", format: "der" }) as Buffer
|
|
21
|
-
return b64urlEncode(der.slice(12)) // strip 12-byte ED25519 SPKI prefix
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let _identity: { deviceId: string; privateKeyPem: string; publicKeyPem: string } | null = null
|
|
25
|
-
let _gatewayToken: string | null = null
|
|
26
|
-
|
|
27
|
-
function loadGatewayConfig(): { identity: typeof _identity; token: string } {
|
|
28
|
-
if (!_identity || !_gatewayToken) {
|
|
29
|
-
const identity = JSON.parse(readFileSync(`${homedir()}/.openclaw/identity/device.json`, "utf8"))
|
|
30
|
-
const cfg = JSON.parse(readFileSync(`${homedir()}/.openclaw/openclaw.json`, "utf8"))
|
|
31
|
-
const token: string = cfg?.gateway?.auth?.token
|
|
32
|
-
if (!token) throw new Error("gateway token not found in openclaw.json")
|
|
33
|
-
_identity = identity
|
|
34
|
-
_gatewayToken = token
|
|
35
|
-
}
|
|
36
|
-
return { identity: _identity!, token: _gatewayToken! }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function invalidateGatewayConfig() {
|
|
40
|
-
_identity = null
|
|
41
|
-
_gatewayToken = null
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function sendViaGateway(
|
|
45
|
-
to: string,
|
|
46
|
-
message?: string,
|
|
47
|
-
mediaUrl?: string
|
|
48
|
-
): Promise<{ ok: boolean; error?: string }> {
|
|
49
|
-
let identity: ReturnType<typeof loadGatewayConfig>["identity"]
|
|
50
|
-
let token: string
|
|
51
|
-
try {
|
|
52
|
-
const cfg = loadGatewayConfig()
|
|
53
|
-
identity = cfg.identity
|
|
54
|
-
token = cfg.token
|
|
55
|
-
} catch (e) {
|
|
56
|
-
invalidateGatewayConfig()
|
|
57
|
-
return { ok: false, error: `config error: ${e instanceof Error ? e.message : String(e)}` }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return new Promise((resolve) => {
|
|
61
|
-
const ws = new WebSocket("ws://127.0.0.1:18789")
|
|
62
|
-
let settled = false
|
|
63
|
-
let connected = false
|
|
64
|
-
|
|
65
|
-
const finish = (result: { ok: boolean; error?: string }) => {
|
|
66
|
-
if (settled) return
|
|
67
|
-
settled = true
|
|
68
|
-
clearTimeout(timer)
|
|
69
|
-
try { ws.close() } catch {}
|
|
70
|
-
resolve(result)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const timer = setTimeout(() => finish({ ok: false, error: "timeout waiting for gateway" }), 10_000)
|
|
74
|
-
|
|
75
|
-
ws.onerror = () => finish({ ok: false, error: "gateway websocket error" })
|
|
76
|
-
|
|
77
|
-
ws.onclose = (event: CloseEvent) => {
|
|
78
|
-
if (!settled) finish({ ok: false, error: `gateway closed unexpectedly (code=${event.code})` })
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
ws.onmessage = (event: MessageEvent) => {
|
|
82
|
-
try {
|
|
83
|
-
const frame = JSON.parse(event.data as string)
|
|
84
|
-
|
|
85
|
-
if (!connected && frame.type === "event" && frame.event === "connect.challenge") {
|
|
86
|
-
const nonce: string = frame.payload.nonce
|
|
87
|
-
const signedAtMs = Date.now()
|
|
88
|
-
const SCOPES = ["operator.admin", "operator.write"]
|
|
89
|
-
const authPayload = ["v2", identity!.deviceId, "cli", "cli", "operator",
|
|
90
|
-
SCOPES.join(","), String(signedAtMs), token, nonce].join("|")
|
|
91
|
-
ws.send(JSON.stringify({
|
|
92
|
-
type: "req", id: "conn1", method: "connect",
|
|
93
|
-
params: {
|
|
94
|
-
minProtocol: 3, maxProtocol: 3,
|
|
95
|
-
client: { id: "cli", version: "1.0.0", platform: "linux", mode: "cli" },
|
|
96
|
-
caps: [],
|
|
97
|
-
scopes: SCOPES,
|
|
98
|
-
auth: { token },
|
|
99
|
-
device: {
|
|
100
|
-
id: identity!.deviceId,
|
|
101
|
-
publicKey: pubKeyRawB64url(identity!.publicKeyPem),
|
|
102
|
-
signature: signPayload(identity!.privateKeyPem, authPayload),
|
|
103
|
-
signedAt: signedAtMs,
|
|
104
|
-
nonce
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}))
|
|
108
|
-
|
|
109
|
-
} else if (!connected && frame.type === "res" && frame.id === "conn1") {
|
|
110
|
-
if (!frame.ok) {
|
|
111
|
-
if (frame.error?.message?.includes("unauthorized") ||
|
|
112
|
-
frame.error?.message?.includes("pairing")) {
|
|
113
|
-
invalidateGatewayConfig()
|
|
114
|
-
}
|
|
115
|
-
finish({ ok: false, error: `gateway connect failed: ${frame.error?.message || "unknown"}` })
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
connected = true
|
|
119
|
-
const sendParams: Record<string, unknown> = {
|
|
120
|
-
to,
|
|
121
|
-
channel: "telegram",
|
|
122
|
-
idempotencyKey: randomBytes(16).toString("hex")
|
|
123
|
-
}
|
|
124
|
-
if (message) sendParams.message = message
|
|
125
|
-
if (mediaUrl) sendParams.mediaUrl = mediaUrl
|
|
126
|
-
ws.send(JSON.stringify({
|
|
127
|
-
type: "req", id: "send1", method: "send",
|
|
128
|
-
params: sendParams
|
|
129
|
-
}))
|
|
130
|
-
|
|
131
|
-
} else if (connected && frame.type === "res" && frame.id === "send1") {
|
|
132
|
-
if (frame.ok) {
|
|
133
|
-
finish({ ok: true })
|
|
134
|
-
} else {
|
|
135
|
-
finish({ ok: false, error: frame.error?.message || "send failed" })
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
} catch (e) {
|
|
139
|
-
finish({ ok: false, error: `parse error: ${e instanceof Error ? e.message : String(e)}` })
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ── MCP server factory ───────────────────────────────────────────────────────
|
|
146
|
-
// Provides only the gateway message tool. File operations, bash, etc. use
|
|
147
|
-
// Claude Code's built-in tools (which are more robust and don't need MCP).
|
|
148
|
-
//
|
|
149
|
-
// state.messageSent is set on successful delivery. The proxy uses this to
|
|
150
|
-
// auto-suppress text responses when messages were sent via tool (prevents
|
|
151
|
-
// double-delivery without requiring Claude to know about any sentinel value).
|
|
152
|
-
|
|
153
|
-
export interface McpServerState { messageSent: boolean }
|
|
154
|
-
|
|
155
|
-
export function createMcpServer(state: McpServerState = { messageSent: false }) {
|
|
156
|
-
return createSdkMcpServer({
|
|
157
|
-
name: "opencode",
|
|
158
|
-
version: "1.0.0",
|
|
159
|
-
tools: [
|
|
160
|
-
tool(
|
|
161
|
-
"message",
|
|
162
|
-
"Send a message or file to a chat. Provide `to` (chat ID from conversation_label, e.g. '-1001426819337'), and either `message` (text) or `filePath`/`path`/`media` (absolute path to a file). Write files to /tmp/ before sending.",
|
|
163
|
-
{
|
|
164
|
-
action: z.string().optional().describe("Action to perform. Default: 'send'."),
|
|
165
|
-
to: z.string().describe("Chat ID, extracted from conversation_label."),
|
|
166
|
-
message: z.string().optional().describe("Text message to send."),
|
|
167
|
-
filePath: z.string().optional().describe("Absolute path to a file to send as attachment."),
|
|
168
|
-
path: z.string().optional().describe("Alias for filePath."),
|
|
169
|
-
media: z.string().optional().describe("Alias for filePath."),
|
|
170
|
-
caption: z.string().optional().describe("Caption for a media attachment."),
|
|
171
|
-
},
|
|
172
|
-
async (args) => {
|
|
173
|
-
try {
|
|
174
|
-
const rawMedia = args.media ?? args.path ?? args.filePath
|
|
175
|
-
let mediaUrl: string | undefined
|
|
176
|
-
if (rawMedia) {
|
|
177
|
-
if (rawMedia.startsWith("http://") || rawMedia.startsWith("https://") || rawMedia.startsWith("file://")) {
|
|
178
|
-
mediaUrl = rawMedia
|
|
179
|
-
} else {
|
|
180
|
-
const absPath = rawMedia.startsWith("/") ? rawMedia : `/tmp/${rawMedia}`
|
|
181
|
-
mediaUrl = `file://${absPath}`
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const textMessage = args.message ?? args.caption
|
|
185
|
-
if (!textMessage && !mediaUrl) {
|
|
186
|
-
return { content: [{ type: "text", text: "Error: provide message or filePath/path/media" }], isError: true }
|
|
187
|
-
}
|
|
188
|
-
const result = await sendViaGateway(args.to, textMessage, mediaUrl)
|
|
189
|
-
if (result.ok) {
|
|
190
|
-
state.messageSent = true
|
|
191
|
-
return { content: [{ type: "text", text: `Sent to ${args.to}` }] }
|
|
192
|
-
}
|
|
193
|
-
return {
|
|
194
|
-
content: [{ type: "text", text: `Failed: ${result.error}` }],
|
|
195
|
-
isError: true
|
|
196
|
-
}
|
|
197
|
-
} catch (error) {
|
|
198
|
-
return {
|
|
199
|
-
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
200
|
-
isError: true
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
)
|
|
205
|
-
]
|
|
206
|
-
})
|
|
207
|
-
}
|
|
1
|
+
// mcpTools.ts — removed. The proxy no longer provides any MCP tools.
|
|
2
|
+
// Tool definitions come from the API caller (standard Anthropic tool loop).
|
|
3
|
+
export {}
|