@sweny-ai/core 0.1.136 → 0.1.138
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/dist/cli/main.js +12 -0
- package/dist/cli/new.js +3 -0
- package/dist/cli/packs.d.ts +20 -0
- package/dist/cli/packs.js +1004 -0
- package/dist/cli/templates.d.ts +13 -0
- package/dist/cli/templates.js +4 -0
- package/dist/harness/claude-code.d.ts +27 -1
- package/dist/harness/claude-code.js +40 -77
- package/dist/harness/index.d.ts +2 -0
- package/dist/harness/index.js +1 -0
- package/dist/harness/tool-bridge/protocol.d.ts +91 -0
- package/dist/harness/tool-bridge/protocol.js +184 -0
- package/dist/harness/tool-bridge/server.d.ts +57 -0
- package/dist/harness/tool-bridge/server.js +189 -0
- package/dist/harness/tool-bridge/shim.d.ts +38 -0
- package/dist/harness/tool-bridge/shim.js +125 -0
- package/dist/skills/github.js +73 -0
- package/package.json +2 -1
package/dist/cli/templates.d.ts
CHANGED
|
@@ -4,10 +4,23 @@
|
|
|
4
4
|
* Each template is a valid SWEny workflow YAML. Templates are self-contained
|
|
5
5
|
* strings — no filesystem reads needed, works from npm install.
|
|
6
6
|
*/
|
|
7
|
+
/** Extra metadata for a recurring pack (#338): how to schedule it and what it produces. */
|
|
8
|
+
export interface WorkflowPack {
|
|
9
|
+
/** A GitHub Actions workflow file to paste into .github/workflows/. */
|
|
10
|
+
trigger: string;
|
|
11
|
+
/** Sample of what the pack posts (issue body, comment, or message). */
|
|
12
|
+
sample: string;
|
|
13
|
+
/** Expected tokens per run. An estimate; the run receipt shows the real number. */
|
|
14
|
+
tokens: string;
|
|
15
|
+
/** Write tools the pack may call, by node id. Every other node is read-only. */
|
|
16
|
+
writes: Record<string, string[]>;
|
|
17
|
+
}
|
|
7
18
|
export interface WorkflowTemplate {
|
|
8
19
|
id: string;
|
|
9
20
|
name: string;
|
|
10
21
|
description: string;
|
|
11
22
|
yaml: string;
|
|
23
|
+
/** Present on the recurring packs only. */
|
|
24
|
+
pack?: WorkflowPack;
|
|
12
25
|
}
|
|
13
26
|
export declare const WORKFLOW_TEMPLATES: WorkflowTemplate[];
|
package/dist/cli/templates.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Each template is a valid SWEny workflow YAML. Templates are self-contained
|
|
5
5
|
* strings — no filesystem reads needed, works from npm install.
|
|
6
6
|
*/
|
|
7
|
+
import { PACK_TEMPLATES } from "./packs.js";
|
|
7
8
|
export const WORKFLOW_TEMPLATES = [
|
|
8
9
|
{
|
|
9
10
|
id: "explain-repo",
|
|
@@ -37,6 +38,9 @@ edges:
|
|
|
37
38
|
to: explain
|
|
38
39
|
`,
|
|
39
40
|
},
|
|
41
|
+
// Recurring packs (#338): run weekly or on every PR. Kept right after the
|
|
42
|
+
// zero-credential starter so they are the next thing sweny new offers.
|
|
43
|
+
...PACK_TEMPLATES,
|
|
40
44
|
{
|
|
41
45
|
id: "pr-review",
|
|
42
46
|
name: "PR Review Bot",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* This is the ONLY supported LLM backend. The whole point of sweny
|
|
9
9
|
* is to use headless Claude Code - never the raw Anthropic API.
|
|
10
10
|
*/
|
|
11
|
-
import type { Claude, ToolContext, Logger, McpServerConfig } from "../types.js";
|
|
11
|
+
import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
|
|
12
12
|
import { type SandboxMode } from "../agent-env.js";
|
|
13
13
|
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult } from "./types.js";
|
|
14
14
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
@@ -107,6 +107,18 @@ export interface ClaudeCodeHarnessOptions {
|
|
|
107
107
|
sandboxAllowedDomains?: string[];
|
|
108
108
|
/** Sandbox preflight probe (test seam). Returns a reason when the sandbox cannot run. */
|
|
109
109
|
sandboxProbe?: () => string | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Serve skill tools through the SwenyToolBridge (a stdio MCP shim over a
|
|
112
|
+
* per-run unix socket, #414) instead of the in-process SDK MCP server.
|
|
113
|
+
* Same tools, same results; used to run the harness contract suite both
|
|
114
|
+
* ways. Default: off, unless `SWENY_TOOL_BRIDGE=1`.
|
|
115
|
+
*/
|
|
116
|
+
toolBridge?: boolean;
|
|
117
|
+
/** Shim command override for the bridge (test seam). Default: this package's `sweny tool-bridge`. */
|
|
118
|
+
toolBridgeShim?: {
|
|
119
|
+
command: string;
|
|
120
|
+
args: string[];
|
|
121
|
+
};
|
|
110
122
|
}
|
|
111
123
|
/**
|
|
112
124
|
* Wire an optional timeout + caller signal onto a single AbortController.
|
|
@@ -139,6 +151,8 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
139
151
|
private sandboxProbe;
|
|
140
152
|
private sandboxWarned;
|
|
141
153
|
private envScope;
|
|
154
|
+
private toolBridge;
|
|
155
|
+
private toolBridgeShim;
|
|
142
156
|
constructor(opts?: ClaudeCodeHarnessOptions);
|
|
143
157
|
/**
|
|
144
158
|
* Build env for the Claude Code subprocess (#360). Applies auth precedence
|
|
@@ -201,6 +215,18 @@ export type ClaudeClientOptions = ClaudeCodeHarnessOptions;
|
|
|
201
215
|
export declare const ClaudeClient: typeof ClaudeCodeHarness;
|
|
202
216
|
/** @deprecated Use {@link ClaudeCodeHarness}. */
|
|
203
217
|
export type ClaudeClient = ClaudeCodeHarness;
|
|
218
|
+
/**
|
|
219
|
+
* Convert a core Tool to an SDK MCP tool definition.
|
|
220
|
+
*
|
|
221
|
+
* The wrapper runs the user's handler and returns the MCP `CallToolResult`.
|
|
222
|
+
* Output bookkeeping happens on the stream side (see the tool_result
|
|
223
|
+
* handler in `run()`), keyed by `tool_use_id` - never from inside the
|
|
224
|
+
* wrapper. Keeping the wrapper stateless means parallel calls to the same
|
|
225
|
+
* tool cannot mis-pair outputs.
|
|
226
|
+
*
|
|
227
|
+
* @internal Exported for the tool bridge parity test (#414).
|
|
228
|
+
*/
|
|
229
|
+
export declare function coreToolToSdkTool(coreTool: Tool, defaultCtx: ToolContext): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<Record<string, import("zod").ZodType<any, import("zod").ZodTypeDef, any>>>;
|
|
204
230
|
/**
|
|
205
231
|
* Best-effort recovery of typed output from a tool_result's `content`.
|
|
206
232
|
*
|
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import { z } from "zod";
|
|
14
13
|
import { consoleLogger } from "../types.js";
|
|
15
14
|
import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, resolveAgentSandbox, } from "../agent-env.js";
|
|
16
15
|
import { fenceUntrustedJson } from "../untrusted.js";
|
|
17
16
|
import { policyGate } from "./policy.js";
|
|
18
17
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
19
18
|
import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt } from "./prompts.js";
|
|
19
|
+
import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } from "./tool-bridge/protocol.js";
|
|
20
|
+
import { startToolBridge } from "./tool-bridge/server.js";
|
|
20
21
|
export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
|
|
21
22
|
let cachedSdkVersion;
|
|
22
23
|
/** Version of the Claude Agent SDK this adapter drives; "unknown" when it cannot be read. */
|
|
@@ -241,6 +242,8 @@ export class ClaudeCodeHarness {
|
|
|
241
242
|
sandboxProbe;
|
|
242
243
|
sandboxWarned = false;
|
|
243
244
|
envScope;
|
|
245
|
+
toolBridge;
|
|
246
|
+
toolBridgeShim;
|
|
244
247
|
constructor(opts = {}) {
|
|
245
248
|
this.model = opts.model;
|
|
246
249
|
this.maxTurns = opts.maxTurns ?? 20;
|
|
@@ -254,6 +257,8 @@ export class ClaudeCodeHarness {
|
|
|
254
257
|
this.sandboxMode = opts.sandbox;
|
|
255
258
|
this.sandboxAllowedDomains = opts.sandboxAllowedDomains;
|
|
256
259
|
this.sandboxProbe = opts.sandboxProbe;
|
|
260
|
+
this.toolBridge = opts.toolBridge ?? process.env.SWENY_TOOL_BRIDGE === "1";
|
|
261
|
+
this.toolBridgeShim = opts.toolBridgeShim;
|
|
257
262
|
}
|
|
258
263
|
/**
|
|
259
264
|
* Build env for the Claude Code subprocess (#360). Applies auth precedence
|
|
@@ -352,12 +357,18 @@ export class ClaudeCodeHarness {
|
|
|
352
357
|
// handler and returns `content` via the MCP transport. We do not push
|
|
353
358
|
// to toolCalls from the wrapper - the stream's tool_result is the sole
|
|
354
359
|
// signal of completion, which keeps pairing correct under parallelism.
|
|
355
|
-
|
|
360
|
+
// #414: with the bridge on, the same tools reach Claude Code through the
|
|
361
|
+
// `sweny tool-bridge` stdio shim instead, so no in-process server is built.
|
|
362
|
+
const useBridge = this.toolBridge && tools.length > 0;
|
|
363
|
+
const sdkTools = useBridge ? [] : tools.map((t) => coreToolToSdkTool(t, this.defaultContext));
|
|
356
364
|
// Create in-process MCP server
|
|
357
|
-
const mcpServer =
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
365
|
+
const mcpServer = useBridge
|
|
366
|
+
? undefined
|
|
367
|
+
: createSdkMcpServer({
|
|
368
|
+
name: "sweny-core",
|
|
369
|
+
tools: sdkTools,
|
|
370
|
+
});
|
|
371
|
+
let bridge;
|
|
361
372
|
// Build prompt. Context (workflow input such as issues/alerts, plus prior
|
|
362
373
|
// node outputs) is fenced as untrusted data (#360).
|
|
363
374
|
const prompt = [
|
|
@@ -394,8 +405,21 @@ export class ClaudeCodeHarness {
|
|
|
394
405
|
const allMcpServers = readOnly
|
|
395
406
|
? {}
|
|
396
407
|
: { ...this.defaultMcpServers, ...opts.mcpServers, ...this.mcpServers };
|
|
397
|
-
if (
|
|
408
|
+
if (useBridge) {
|
|
409
|
+
// Only `tools` (already allow/deny and dry-run filtered by the
|
|
410
|
+
// executor) are exposed. Under readOnly it is still the only MCP
|
|
411
|
+
// server, and strictMcpConfig below keeps it that way.
|
|
412
|
+
bridge = await startToolBridge({
|
|
413
|
+
tools,
|
|
414
|
+
context: this.defaultContext,
|
|
415
|
+
logger: this.logger,
|
|
416
|
+
...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
|
|
417
|
+
});
|
|
418
|
+
allMcpServers["sweny-core"] = bridge.mcpServer;
|
|
419
|
+
}
|
|
420
|
+
else if (sdkTools.length > 0) {
|
|
398
421
|
allMcpServers["sweny-core"] = mcpServer;
|
|
422
|
+
}
|
|
399
423
|
stream = query({
|
|
400
424
|
prompt,
|
|
401
425
|
options: {
|
|
@@ -567,6 +591,8 @@ export class ClaudeCodeHarness {
|
|
|
567
591
|
// it cannot fire after we are done.
|
|
568
592
|
abort?.clear();
|
|
569
593
|
await interruptStream(stream);
|
|
594
|
+
// #414: the socket and its directory go away with the run.
|
|
595
|
+
await bridge?.close();
|
|
570
596
|
}
|
|
571
597
|
if (!sawResult) {
|
|
572
598
|
const msg = "agent stream ended without a result message";
|
|
@@ -703,8 +729,10 @@ export const ClaudeClient = ClaudeCodeHarness;
|
|
|
703
729
|
* handler in `run()`), keyed by `tool_use_id` - never from inside the
|
|
704
730
|
* wrapper. Keeping the wrapper stateless means parallel calls to the same
|
|
705
731
|
* tool cannot mis-pair outputs.
|
|
732
|
+
*
|
|
733
|
+
* @internal Exported for the tool bridge parity test (#414).
|
|
706
734
|
*/
|
|
707
|
-
function coreToolToSdkTool(coreTool, defaultCtx) {
|
|
735
|
+
export function coreToolToSdkTool(coreTool, defaultCtx) {
|
|
708
736
|
const zodShape = jsonSchemaToZodShape(coreTool.input_schema);
|
|
709
737
|
return sdkTool(coreTool.name, coreTool.description, zodShape, async (args) => {
|
|
710
738
|
try {
|
|
@@ -712,16 +740,12 @@ function coreToolToSdkTool(coreTool, defaultCtx) {
|
|
|
712
740
|
// When used standalone, defaultCtx is the fallback.
|
|
713
741
|
const output = await coreTool.handler(args, defaultCtx);
|
|
714
742
|
// JSON-stringify structured output so the tool_result handler can
|
|
715
|
-
// recover it via parseToolResultContent. Strings pass through.
|
|
716
|
-
return
|
|
717
|
-
|
|
718
|
-
};
|
|
743
|
+
// recover it via parseToolResultContent. Strings pass through. Shared
|
|
744
|
+
// with the tool bridge so both paths return the same content.
|
|
745
|
+
return toolOutputToMcpResult(output);
|
|
719
746
|
}
|
|
720
747
|
catch (err) {
|
|
721
|
-
return
|
|
722
|
-
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
723
|
-
isError: true,
|
|
724
|
-
};
|
|
748
|
+
return toolErrorToMcpResult(err);
|
|
725
749
|
}
|
|
726
750
|
});
|
|
727
751
|
}
|
|
@@ -831,67 +855,6 @@ export function summarizeToolError(parsed) {
|
|
|
831
855
|
const collapsed = raw.replace(/\s+/g, " ").trim();
|
|
832
856
|
return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed;
|
|
833
857
|
}
|
|
834
|
-
// ─── JSON Schema → Zod conversion ───────────────────────────────
|
|
835
|
-
/**
|
|
836
|
-
* Convert a JSON Schema object to a Zod raw shape for the agent SDK.
|
|
837
|
-
* Preserves property names, types, and descriptions so Claude sees
|
|
838
|
-
* accurate tool parameters through the MCP protocol.
|
|
839
|
-
*/
|
|
840
|
-
function jsonSchemaToZodShape(schema) {
|
|
841
|
-
const props = schema?.properties ?? {};
|
|
842
|
-
const required = new Set(schema?.required ?? []);
|
|
843
|
-
const shape = {};
|
|
844
|
-
for (const [key, prop] of Object.entries(props)) {
|
|
845
|
-
let zodType = jsonPropertyToZod(prop);
|
|
846
|
-
if (!required.has(key)) {
|
|
847
|
-
zodType = zodType.optional();
|
|
848
|
-
}
|
|
849
|
-
shape[key] = zodType;
|
|
850
|
-
}
|
|
851
|
-
return shape;
|
|
852
|
-
}
|
|
853
|
-
function jsonPropertyToZod(prop) {
|
|
854
|
-
if (!prop || typeof prop !== "object")
|
|
855
|
-
return z.unknown();
|
|
856
|
-
const desc = typeof prop.description === "string" ? prop.description : undefined;
|
|
857
|
-
switch (prop.type) {
|
|
858
|
-
case "string": {
|
|
859
|
-
if (prop.enum && Array.isArray(prop.enum)) {
|
|
860
|
-
const e = z.enum(prop.enum);
|
|
861
|
-
return desc ? e.describe(desc) : e;
|
|
862
|
-
}
|
|
863
|
-
const s = z.string();
|
|
864
|
-
return desc ? s.describe(desc) : s;
|
|
865
|
-
}
|
|
866
|
-
case "number":
|
|
867
|
-
case "integer": {
|
|
868
|
-
const n = z.number();
|
|
869
|
-
return desc ? n.describe(desc) : n;
|
|
870
|
-
}
|
|
871
|
-
case "boolean": {
|
|
872
|
-
const b = z.boolean();
|
|
873
|
-
return desc ? b.describe(desc) : b;
|
|
874
|
-
}
|
|
875
|
-
case "array": {
|
|
876
|
-
const items = prop.items ? jsonPropertyToZod(prop.items) : z.unknown();
|
|
877
|
-
const a = z.array(items);
|
|
878
|
-
return desc ? a.describe(desc) : a;
|
|
879
|
-
}
|
|
880
|
-
case "object": {
|
|
881
|
-
if (prop.properties && typeof prop.properties === "object") {
|
|
882
|
-
const nested = jsonSchemaToZodShape(prop);
|
|
883
|
-
const o = z.object(nested);
|
|
884
|
-
return desc ? o.describe(desc) : o;
|
|
885
|
-
}
|
|
886
|
-
const r = z.record(z.string(), z.unknown());
|
|
887
|
-
return desc ? r.describe(desc) : r;
|
|
888
|
-
}
|
|
889
|
-
default: {
|
|
890
|
-
const u = z.unknown();
|
|
891
|
-
return desc ? u.describe(desc) : u;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
858
|
/** Strip MCP server prefix: "mcp__server__tool" → "tool" */
|
|
896
859
|
function stripMcpPrefix(name) {
|
|
897
860
|
const parts = name.split("__");
|
package/dist/harness/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { policyGate } from "./policy.js";
|
|
|
9
9
|
export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
|
|
10
10
|
export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
|
|
11
11
|
export type { ClaudeCodeHarnessOptions } from "./claude-code.js";
|
|
12
|
+
export { startToolBridge } from "./tool-bridge/server.js";
|
|
13
|
+
export type { ToolBridge, ToolBridgeOptions } from "./tool-bridge/server.js";
|
|
12
14
|
/**
|
|
13
15
|
* Build the harness for an agent id. `claude` is the historical `--agent` /
|
|
14
16
|
* `coding-agent-provider` value and means the same as `claude-code`.
|
package/dist/harness/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { claudeCompat, asClaude } from "./compat.js";
|
|
|
7
7
|
export { policyGate } from "./policy.js";
|
|
8
8
|
export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
|
|
9
9
|
export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
|
|
10
|
+
export { startToolBridge } from "./tool-bridge/server.js";
|
|
10
11
|
/**
|
|
11
12
|
* Build the harness for an agent id. `claude` is the historical `--agent` /
|
|
12
13
|
* `coding-agent-provider` value and means the same as `claude-code`.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwenyToolBridge wire protocol (#414).
|
|
3
|
+
*
|
|
4
|
+
* The bridge moves skill tool calls from a harness's stdio MCP client to the
|
|
5
|
+
* sweny process that owns the tool handlers:
|
|
6
|
+
*
|
|
7
|
+
* harness --stdio MCP--> shim (`sweny tool-bridge`) --unix socket--> server (sweny run)
|
|
8
|
+
*
|
|
9
|
+
* The socket carries newline-delimited JSON frames. Every request carries the
|
|
10
|
+
* per-run token; the server rejects a frame with a wrong or missing token and
|
|
11
|
+
* drops the connection.
|
|
12
|
+
*
|
|
13
|
+
* This module also owns the one function that turns a core `Tool` handler
|
|
14
|
+
* outcome into an MCP `CallToolResult`, so the in-process Claude path
|
|
15
|
+
* (`createSdkMcpServer`) and the bridge return byte-identical content and the
|
|
16
|
+
* same `isError` status.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import type { JSONSchema, Tool, ToolContext } from "../../types.js";
|
|
20
|
+
export declare const BRIDGE_PROTOCOL_VERSION = 1;
|
|
21
|
+
/** Frames longer than this are refused and the connection is dropped. */
|
|
22
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
23
|
+
/** Env var the shim reads the per-run token from (kept out of argv by default). */
|
|
24
|
+
export declare const TOKEN_ENV = "SWENY_TOOL_BRIDGE_TOKEN";
|
|
25
|
+
export type BridgeMethod = "tools/list" | "tools/call";
|
|
26
|
+
export interface BridgeRequest {
|
|
27
|
+
v: typeof BRIDGE_PROTOCOL_VERSION;
|
|
28
|
+
id: number;
|
|
29
|
+
token: string;
|
|
30
|
+
method: BridgeMethod;
|
|
31
|
+
params?: {
|
|
32
|
+
name?: string;
|
|
33
|
+
arguments?: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface BridgeToolDescriptor {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
inputSchema: JSONSchema;
|
|
40
|
+
}
|
|
41
|
+
export type McpTextContent = {
|
|
42
|
+
type: "text";
|
|
43
|
+
text: string;
|
|
44
|
+
};
|
|
45
|
+
/** The MCP `CallToolResult` subset sweny produces for skill tools. */
|
|
46
|
+
export type McpCallToolResult = {
|
|
47
|
+
content: McpTextContent[];
|
|
48
|
+
isError?: true;
|
|
49
|
+
};
|
|
50
|
+
export type BridgeResponse = {
|
|
51
|
+
id: number;
|
|
52
|
+
ok: true;
|
|
53
|
+
result: {
|
|
54
|
+
tools: BridgeToolDescriptor[];
|
|
55
|
+
} | McpCallToolResult;
|
|
56
|
+
} | {
|
|
57
|
+
id: number;
|
|
58
|
+
ok: false;
|
|
59
|
+
error: {
|
|
60
|
+
code: BridgeErrorCode;
|
|
61
|
+
message: string;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
export type BridgeErrorCode = "unauthorized" | "bad_request" | "unknown_tool" | "internal";
|
|
65
|
+
export declare function encodeFrame(frame: BridgeRequest | BridgeResponse): string;
|
|
66
|
+
/**
|
|
67
|
+
* Split a byte stream into newline-delimited JSON frames. Calls `onFrame` with
|
|
68
|
+
* each parsed value (or `undefined` for a line that is not JSON) and
|
|
69
|
+
* `onOverflow` once when a single frame exceeds {@link MAX_FRAME_BYTES}.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createFrameReader(onFrame: (value: unknown) => void, onOverflow: () => void, maxBytes?: number): (chunk: Buffer | string) => void;
|
|
72
|
+
/** Constant-time token comparison. False for any non-string or length mismatch. */
|
|
73
|
+
export declare function tokenMatches(expected: string, given: unknown): boolean;
|
|
74
|
+
/** Structural check of an incoming request frame (token checked separately). */
|
|
75
|
+
export declare function parseRequest(value: unknown): BridgeRequest | undefined;
|
|
76
|
+
/** A handler's return value as MCP content. Strings pass through; anything else is JSON. */
|
|
77
|
+
export declare function toolOutputToMcpResult(output: unknown): McpCallToolResult;
|
|
78
|
+
/** A handler's thrown error as an MCP error result. */
|
|
79
|
+
export declare function toolErrorToMcpResult(err: unknown): McpCallToolResult;
|
|
80
|
+
/**
|
|
81
|
+
* Validate args against the tool's input schema (the same Zod shape the
|
|
82
|
+
* in-process SDK server validates with), run the handler, and return the MCP
|
|
83
|
+
* result. Invalid args come back as an error result, never reach the handler.
|
|
84
|
+
*/
|
|
85
|
+
export declare function callToolAsMcp(tool: Tool, args: unknown, ctx: ToolContext): Promise<McpCallToolResult>;
|
|
86
|
+
/**
|
|
87
|
+
* Convert a JSON Schema object to a Zod raw shape. The in-process SDK server
|
|
88
|
+
* and the bridge both validate tool args with it, so both paths accept and
|
|
89
|
+
* reject the same inputs and preserve property names, types and descriptions.
|
|
90
|
+
*/
|
|
91
|
+
export declare function jsonSchemaToZodShape(schema: JSONSchema): Record<string, z.ZodType>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwenyToolBridge wire protocol (#414).
|
|
3
|
+
*
|
|
4
|
+
* The bridge moves skill tool calls from a harness's stdio MCP client to the
|
|
5
|
+
* sweny process that owns the tool handlers:
|
|
6
|
+
*
|
|
7
|
+
* harness --stdio MCP--> shim (`sweny tool-bridge`) --unix socket--> server (sweny run)
|
|
8
|
+
*
|
|
9
|
+
* The socket carries newline-delimited JSON frames. Every request carries the
|
|
10
|
+
* per-run token; the server rejects a frame with a wrong or missing token and
|
|
11
|
+
* drops the connection.
|
|
12
|
+
*
|
|
13
|
+
* This module also owns the one function that turns a core `Tool` handler
|
|
14
|
+
* outcome into an MCP `CallToolResult`, so the in-process Claude path
|
|
15
|
+
* (`createSdkMcpServer`) and the bridge return byte-identical content and the
|
|
16
|
+
* same `isError` status.
|
|
17
|
+
*/
|
|
18
|
+
import { timingSafeEqual } from "node:crypto";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
export const BRIDGE_PROTOCOL_VERSION = 1;
|
|
21
|
+
/** Frames longer than this are refused and the connection is dropped. */
|
|
22
|
+
export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
23
|
+
/** Env var the shim reads the per-run token from (kept out of argv by default). */
|
|
24
|
+
export const TOKEN_ENV = "SWENY_TOOL_BRIDGE_TOKEN";
|
|
25
|
+
export function encodeFrame(frame) {
|
|
26
|
+
return JSON.stringify(frame) + "\n";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Split a byte stream into newline-delimited JSON frames. Calls `onFrame` with
|
|
30
|
+
* each parsed value (or `undefined` for a line that is not JSON) and
|
|
31
|
+
* `onOverflow` once when a single frame exceeds {@link MAX_FRAME_BYTES}.
|
|
32
|
+
*/
|
|
33
|
+
export function createFrameReader(onFrame, onOverflow, maxBytes = MAX_FRAME_BYTES) {
|
|
34
|
+
let buf = "";
|
|
35
|
+
let overflowed = false;
|
|
36
|
+
return (chunk) => {
|
|
37
|
+
if (overflowed)
|
|
38
|
+
return;
|
|
39
|
+
buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
40
|
+
let nl;
|
|
41
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
42
|
+
const line = buf.slice(0, nl);
|
|
43
|
+
buf = buf.slice(nl + 1);
|
|
44
|
+
if (line.trim() === "")
|
|
45
|
+
continue;
|
|
46
|
+
let value;
|
|
47
|
+
try {
|
|
48
|
+
value = JSON.parse(line);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
value = undefined;
|
|
52
|
+
}
|
|
53
|
+
onFrame(value);
|
|
54
|
+
}
|
|
55
|
+
if (Buffer.byteLength(buf, "utf8") > maxBytes) {
|
|
56
|
+
overflowed = true;
|
|
57
|
+
buf = "";
|
|
58
|
+
onOverflow();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Constant-time token comparison. False for any non-string or length mismatch. */
|
|
63
|
+
export function tokenMatches(expected, given) {
|
|
64
|
+
if (typeof given !== "string")
|
|
65
|
+
return false;
|
|
66
|
+
const a = Buffer.from(expected, "utf8");
|
|
67
|
+
const b = Buffer.from(given, "utf8");
|
|
68
|
+
if (a.length !== b.length)
|
|
69
|
+
return false;
|
|
70
|
+
return timingSafeEqual(a, b);
|
|
71
|
+
}
|
|
72
|
+
/** Structural check of an incoming request frame (token checked separately). */
|
|
73
|
+
export function parseRequest(value) {
|
|
74
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
75
|
+
return undefined;
|
|
76
|
+
const r = value;
|
|
77
|
+
if (r.v !== BRIDGE_PROTOCOL_VERSION)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (typeof r.id !== "number" || !Number.isInteger(r.id))
|
|
80
|
+
return undefined;
|
|
81
|
+
if (r.method !== "tools/list" && r.method !== "tools/call")
|
|
82
|
+
return undefined;
|
|
83
|
+
if (r.params !== undefined && (typeof r.params !== "object" || r.params === null || Array.isArray(r.params))) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
// ─── Tool result shape (shared with the in-process Claude path) ─────
|
|
89
|
+
/** A handler's return value as MCP content. Strings pass through; anything else is JSON. */
|
|
90
|
+
export function toolOutputToMcpResult(output) {
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** A handler's thrown error as an MCP error result. */
|
|
96
|
+
export function toolErrorToMcpResult(err) {
|
|
97
|
+
const message = err instanceof Error ? err.message : err?.message;
|
|
98
|
+
return {
|
|
99
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
100
|
+
isError: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Validate args against the tool's input schema (the same Zod shape the
|
|
105
|
+
* in-process SDK server validates with), run the handler, and return the MCP
|
|
106
|
+
* result. Invalid args come back as an error result, never reach the handler.
|
|
107
|
+
*/
|
|
108
|
+
export async function callToolAsMcp(tool, args, ctx) {
|
|
109
|
+
const parsed = z.object(jsonSchemaToZodShape(tool.input_schema)).safeParse(args ?? {});
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text: `Input validation error: invalid arguments for tool ${tool.name}: ${detail}` }],
|
|
114
|
+
isError: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return toolOutputToMcpResult(await tool.handler(parsed.data, ctx));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return toolErrorToMcpResult(err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ─── JSON Schema → Zod conversion ───────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* Convert a JSON Schema object to a Zod raw shape. The in-process SDK server
|
|
127
|
+
* and the bridge both validate tool args with it, so both paths accept and
|
|
128
|
+
* reject the same inputs and preserve property names, types and descriptions.
|
|
129
|
+
*/
|
|
130
|
+
export function jsonSchemaToZodShape(schema) {
|
|
131
|
+
const props = schema?.properties ?? {};
|
|
132
|
+
const required = new Set(schema?.required ?? []);
|
|
133
|
+
const shape = {};
|
|
134
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
135
|
+
let zodType = jsonPropertyToZod(prop);
|
|
136
|
+
if (!required.has(key)) {
|
|
137
|
+
zodType = zodType.optional();
|
|
138
|
+
}
|
|
139
|
+
shape[key] = zodType;
|
|
140
|
+
}
|
|
141
|
+
return shape;
|
|
142
|
+
}
|
|
143
|
+
function jsonPropertyToZod(prop) {
|
|
144
|
+
if (!prop || typeof prop !== "object")
|
|
145
|
+
return z.unknown();
|
|
146
|
+
const desc = typeof prop.description === "string" ? prop.description : undefined;
|
|
147
|
+
switch (prop.type) {
|
|
148
|
+
case "string": {
|
|
149
|
+
if (prop.enum && Array.isArray(prop.enum)) {
|
|
150
|
+
const e = z.enum(prop.enum);
|
|
151
|
+
return desc ? e.describe(desc) : e;
|
|
152
|
+
}
|
|
153
|
+
const s = z.string();
|
|
154
|
+
return desc ? s.describe(desc) : s;
|
|
155
|
+
}
|
|
156
|
+
case "number":
|
|
157
|
+
case "integer": {
|
|
158
|
+
const n = z.number();
|
|
159
|
+
return desc ? n.describe(desc) : n;
|
|
160
|
+
}
|
|
161
|
+
case "boolean": {
|
|
162
|
+
const b = z.boolean();
|
|
163
|
+
return desc ? b.describe(desc) : b;
|
|
164
|
+
}
|
|
165
|
+
case "array": {
|
|
166
|
+
const items = prop.items ? jsonPropertyToZod(prop.items) : z.unknown();
|
|
167
|
+
const a = z.array(items);
|
|
168
|
+
return desc ? a.describe(desc) : a;
|
|
169
|
+
}
|
|
170
|
+
case "object": {
|
|
171
|
+
if (prop.properties && typeof prop.properties === "object") {
|
|
172
|
+
const nested = jsonSchemaToZodShape(prop);
|
|
173
|
+
const o = z.object(nested);
|
|
174
|
+
return desc ? o.describe(desc) : o;
|
|
175
|
+
}
|
|
176
|
+
const r = z.record(z.string(), z.unknown());
|
|
177
|
+
return desc ? r.describe(desc) : r;
|
|
178
|
+
}
|
|
179
|
+
default: {
|
|
180
|
+
const u = z.unknown();
|
|
181
|
+
return desc ? u.describe(desc) : u;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwenyToolBridge server (#414): the sweny side of the bridge.
|
|
3
|
+
*
|
|
4
|
+
* Holds a node's skill `Tool[]` in the sweny process and serves them over a
|
|
5
|
+
* per-run unix socket to the stdio MCP shim (`sweny tool-bridge`), so any
|
|
6
|
+
* harness that speaks stdio MCP gets skill tools while the handlers, and the
|
|
7
|
+
* executor's tool:call / tool:result observation, stay in sweny.
|
|
8
|
+
*
|
|
9
|
+
* Security properties (this is an injection boundary):
|
|
10
|
+
* - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
|
|
11
|
+
* chmod 0600, so other users cannot connect.
|
|
12
|
+
* - A random 256-bit token is required on every frame. A wrong or missing
|
|
13
|
+
* token gets an `unauthorized` error and the connection is dropped.
|
|
14
|
+
* - Only the tools passed in are listed or callable. The executor has already
|
|
15
|
+
* applied the node's allow/deny filter and the dry-run read-only filter, so
|
|
16
|
+
* the bridge can never widen what the node sees.
|
|
17
|
+
* - Tool results use the same MCP content and `isError` status as the
|
|
18
|
+
* in-process path (`callToolAsMcp`).
|
|
19
|
+
* - `close()` removes the socket and its directory; a process `exit` hook
|
|
20
|
+
* and SIGINT/SIGTERM handlers do the same if the run crashes first.
|
|
21
|
+
* - Nothing here logs the tool list, tool arguments or results.
|
|
22
|
+
*/
|
|
23
|
+
import type { Logger, McpServerConfig, Tool, ToolContext } from "../../types.js";
|
|
24
|
+
export interface ToolBridgeOptions {
|
|
25
|
+
/** The node's skill tools, already allow/deny and read-only filtered by core. */
|
|
26
|
+
tools: Tool[];
|
|
27
|
+
/** Context passed to handlers (the executor's tracked handlers ignore it and inject their own). */
|
|
28
|
+
context: ToolContext;
|
|
29
|
+
logger?: Pick<Logger, "debug" | "warn">;
|
|
30
|
+
/**
|
|
31
|
+
* Command that starts the shim. Default: this node binary running the
|
|
32
|
+
* sweny CLI next to this module (`dist/cli/main.js tool-bridge`).
|
|
33
|
+
*/
|
|
34
|
+
shimCommand?: {
|
|
35
|
+
command: string;
|
|
36
|
+
args: string[];
|
|
37
|
+
};
|
|
38
|
+
/** Parent directory for the private socket dir. Default: `os.tmpdir()`. */
|
|
39
|
+
tmpDir?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ToolBridge {
|
|
42
|
+
/** Absolute path of the unix socket. */
|
|
43
|
+
readonly socketPath: string;
|
|
44
|
+
/** Per-run token. Never log it. */
|
|
45
|
+
readonly token: string;
|
|
46
|
+
/** stdio MCP server config a harness uses to start the shim. The token rides in `env`, not argv. */
|
|
47
|
+
readonly mcpServer: McpServerConfig;
|
|
48
|
+
/** Stop serving, drop connections, remove the socket and its directory. Idempotent. */
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/** Default shim command: the sweny CLI that ships next to this module. */
|
|
52
|
+
export declare function defaultShimCommand(): {
|
|
53
|
+
command: string;
|
|
54
|
+
args: string[];
|
|
55
|
+
};
|
|
56
|
+
/** Start a bridge for one node run. Call `close()` when the run ends. */
|
|
57
|
+
export declare function startToolBridge(opts: ToolBridgeOptions): Promise<ToolBridge>;
|