@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.
@@ -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[];
@@ -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
- const sdkTools = tools.map((t) => coreToolToSdkTool(t, this.defaultContext));
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 = createSdkMcpServer({
358
- name: "sweny-core",
359
- tools: sdkTools,
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 (sdkTools.length > 0)
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
- content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
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("__");
@@ -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`.
@@ -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>;