@sweny-ai/core 0.1.150 → 0.1.152
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/builtin-workflows.js +2 -1
- package/dist/cli/main.js +3 -2
- package/dist/harness/pi.js +14 -3
- package/dist/harness/tool-bridge/server.d.ts +20 -0
- package/dist/harness/tool-bridge/server.js +27 -10
- package/dist/harness/tool-bridge/shim.d.ts +41 -4
- package/dist/harness/tool-bridge/shim.js +118 -17
- package/dist/safe-outputs.d.ts +3 -1
- package/dist/safe-outputs.js +48 -10
- package/dist/schema.d.ts +49 -28
- package/dist/schema.js +26 -4
- package/dist/skills/github.js +24 -0
- package/dist/skills/linear.js +44 -0
- package/dist/types.d.ts +11 -5
- package/dist/types.js +5 -3
- package/dist/workflows/browser.js +1 -1
- package/dist/workflows/triage.yml +18 -7
- package/package.json +1 -1
|
@@ -33,6 +33,7 @@ const LEGACY_WRITE_TOOLS = {
|
|
|
33
33
|
comment: ["github_add_comment", "linear_add_comment", "linear_update_issue"],
|
|
34
34
|
pr: ["github_create_pr"],
|
|
35
35
|
label: ["github_add_labels"],
|
|
36
|
+
issue_state: ["linear_update_issue", "linear_set_issue_state", "github_set_issue_state"],
|
|
36
37
|
};
|
|
37
38
|
const writeToolsBySkill = new Map(builtinSkills.map((s) => [s.id, s.tools.filter((t) => t.access !== "read").map((t) => t.name)]));
|
|
38
39
|
function skillWriteTools(node) {
|
|
@@ -59,7 +60,7 @@ export function readGaps(p) {
|
|
|
59
60
|
}
|
|
60
61
|
/** The skill that applies this output type for these providers, or undefined when none can. */
|
|
61
62
|
export function outputSkill(type, p) {
|
|
62
|
-
if (type === "issue" || type === "comment")
|
|
63
|
+
if (type === "issue" || type === "comment" || type === "issue_state")
|
|
63
64
|
return TRACKER_SKILL[p.issueTracker];
|
|
64
65
|
return p.sourceControl === "github" ? "github" : undefined;
|
|
65
66
|
}
|
package/dist/cli/main.js
CHANGED
|
@@ -1313,11 +1313,12 @@ workflowCmd
|
|
|
1313
1313
|
program
|
|
1314
1314
|
.command("tool-bridge", { hidden: true })
|
|
1315
1315
|
.description("Internal: stdio MCP shim for sweny skill tools")
|
|
1316
|
-
.
|
|
1316
|
+
.option("--socket <path>", "Per-run bridge socket")
|
|
1317
|
+
.option("--connect <host:port>", "Per-run bridge TCP endpoint (inside the process sandbox, via its proxy)")
|
|
1317
1318
|
.option("--token <token>", "Per-run token (default: SWENY_TOOL_BRIDGE_TOKEN)")
|
|
1318
1319
|
.action(async (options) => {
|
|
1319
1320
|
const { runToolBridgeShim } = await import("../harness/tool-bridge/shim.js");
|
|
1320
|
-
await runToolBridgeShim({ socket: options.socket, token: options.token, version });
|
|
1321
|
+
await runToolBridgeShim({ socket: options.socket, connect: options.connect, token: options.token, version });
|
|
1321
1322
|
});
|
|
1322
1323
|
// ── sweny upgrade / update ────────────────────────────────────────────
|
|
1323
1324
|
// Self-update the globally-installed @sweny-ai/core. Mirrors the UX of
|
package/dist/harness/pi.js
CHANGED
|
@@ -771,15 +771,24 @@ export class PiHarness {
|
|
|
771
771
|
tools: req.tools,
|
|
772
772
|
context: this.defaultContext,
|
|
773
773
|
logger: this.logger,
|
|
774
|
+
// Wrapped, the shim cannot open the unix socket (srt blocks AF_UNIX on
|
|
775
|
+
// Linux); it tunnels to a loopback port through the sandbox proxy (#439).
|
|
776
|
+
tcp: wrapper !== undefined,
|
|
774
777
|
...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
|
|
775
778
|
});
|
|
776
779
|
const shim = bridge.mcpServer;
|
|
777
780
|
// The token reaches the shim by reference (`${VAR}`), never in a file or argv.
|
|
778
781
|
env[TOKEN_ENV] = bridge.token;
|
|
782
|
+
const ref = (name) => "${" + name + "}";
|
|
779
783
|
entries[PI_BRIDGE_SERVER] = {
|
|
780
784
|
command: shim.command,
|
|
781
785
|
args: shim.args ?? [],
|
|
782
|
-
env: {
|
|
786
|
+
env: {
|
|
787
|
+
SWENY_NO_UPDATE_CHECK: "1",
|
|
788
|
+
[TOKEN_ENV]: ref(TOKEN_ENV),
|
|
789
|
+
// The proxy srt sets for pi, which pi's MCP client does not pass on by itself.
|
|
790
|
+
...(bridge.tcp ? { HTTP_PROXY: ref("HTTP_PROXY"), http_proxy: ref("http_proxy") } : {}),
|
|
791
|
+
},
|
|
783
792
|
exposure: "direct",
|
|
784
793
|
timeout: 600,
|
|
785
794
|
};
|
|
@@ -802,8 +811,10 @@ export class PiHarness {
|
|
|
802
811
|
toolArgs.push("--exclude-tools", [...excluded].join(","));
|
|
803
812
|
}
|
|
804
813
|
const args = [...this.baseArgs(model, Object.keys(entries).length > 0), ...toolArgs];
|
|
805
|
-
const
|
|
806
|
-
|
|
814
|
+
const backendHosts = piBackendHosts(process.env, model);
|
|
815
|
+
// The bridge's loopback endpoint (wrapped runs only): the one extra host the shim needs.
|
|
816
|
+
const harnessEgress = [...backendHosts, ...(bridge?.egress ?? [])];
|
|
817
|
+
if (mode !== "off" && backendHosts.length === 0 && !this.egressWarned) {
|
|
807
818
|
this.egressWarned = true;
|
|
808
819
|
this.logger.warn("pi runs inside the sandbox wrapper with no known model API host; add it with SWENY_SANDBOX_ALLOWED_DOMAINS.");
|
|
809
820
|
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* Security properties (this is an injection boundary):
|
|
10
10
|
* - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
|
|
11
11
|
* chmod 0600, so other users cannot connect.
|
|
12
|
+
* - With `tcp` (only for agents inside the process sandbox wrapper, #439) the
|
|
13
|
+
* same handler also listens on 127.0.0.1:<random port>. Other local users
|
|
14
|
+
* can open that port, so there the token is the only gate; the sandboxed
|
|
15
|
+
* agent reaches it through srt's proxy only because the adapter allowlists
|
|
16
|
+
* exactly that host:port for the run.
|
|
12
17
|
* - A random 256-bit token is required on every frame. A wrong or missing
|
|
13
18
|
* token gets an `unauthorized` error and the connection is dropped.
|
|
14
19
|
* - Only the tools passed in are listed or callable. The executor has already
|
|
@@ -37,12 +42,27 @@ export interface ToolBridgeOptions {
|
|
|
37
42
|
};
|
|
38
43
|
/** Parent directory for the private socket dir. Default: `os.tmpdir()`. */
|
|
39
44
|
tmpDir?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Also serve on a loopback TCP port, and point the shim there (#439). For an
|
|
47
|
+
* agent inside the process sandbox wrapper: srt blocks `socket(AF_UNIX)` on
|
|
48
|
+
* Linux, so the shim cannot reach the unix socket, but it can reach an
|
|
49
|
+
* allowlisted host through srt's egress proxy. The adapter adds
|
|
50
|
+
* {@link ToolBridge.egress} to the node's egress; nothing else is opened.
|
|
51
|
+
*/
|
|
52
|
+
tcp?: boolean;
|
|
40
53
|
}
|
|
41
54
|
export interface ToolBridge {
|
|
42
55
|
/** Absolute path of the unix socket. */
|
|
43
56
|
readonly socketPath: string;
|
|
44
57
|
/** Per-run token. Never log it. */
|
|
45
58
|
readonly token: string;
|
|
59
|
+
/** Loopback TCP endpoint, when started with `tcp: true`. */
|
|
60
|
+
readonly tcp?: {
|
|
61
|
+
host: string;
|
|
62
|
+
port: number;
|
|
63
|
+
};
|
|
64
|
+
/** Hosts a sandboxed shim must reach (`127.0.0.1:<port>` with `tcp`, else none). */
|
|
65
|
+
readonly egress: string[];
|
|
46
66
|
/** stdio MCP server config a harness uses to start the shim. The token rides in `env`, not argv. */
|
|
47
67
|
readonly mcpServer: McpServerConfig;
|
|
48
68
|
/** Stop serving, drop connections, remove the socket and its directory. Idempotent. */
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* Security properties (this is an injection boundary):
|
|
10
10
|
* - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
|
|
11
11
|
* chmod 0600, so other users cannot connect.
|
|
12
|
+
* - With `tcp` (only for agents inside the process sandbox wrapper, #439) the
|
|
13
|
+
* same handler also listens on 127.0.0.1:<random port>. Other local users
|
|
14
|
+
* can open that port, so there the token is the only gate; the sandboxed
|
|
15
|
+
* agent reaches it through srt's proxy only because the adapter allowlists
|
|
16
|
+
* exactly that host:port for the run.
|
|
12
17
|
* - A random 256-bit token is required on every frame. A wrong or missing
|
|
13
18
|
* token gets an `unauthorized` error and the connection is dropped.
|
|
14
19
|
* - Only the tools passed in are listed or callable. The executor has already
|
|
@@ -98,7 +103,7 @@ export async function startToolBridge(opts) {
|
|
|
98
103
|
liveDirs.add(dir);
|
|
99
104
|
installCrashHooks();
|
|
100
105
|
const sockets = new Set();
|
|
101
|
-
const
|
|
106
|
+
const onConnection = (sock) => {
|
|
102
107
|
sockets.add(sock);
|
|
103
108
|
sock.on("close", () => sockets.delete(sock));
|
|
104
109
|
sock.on("error", () => sock.destroy());
|
|
@@ -143,19 +148,29 @@ export async function startToolBridge(opts) {
|
|
|
143
148
|
sock.destroy();
|
|
144
149
|
});
|
|
145
150
|
sock.on("data", read);
|
|
151
|
+
};
|
|
152
|
+
const server = net.createServer(onConnection);
|
|
153
|
+
// Same handler and token check; only started for sandboxed agents.
|
|
154
|
+
const tcpServer = opts.tcp ? net.createServer(onConnection) : undefined;
|
|
155
|
+
const listen = (s, options) => new Promise((resolve, reject) => {
|
|
156
|
+
s.once("error", reject);
|
|
157
|
+
s.listen(options, () => {
|
|
158
|
+
s.removeListener("error", reject);
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
146
161
|
});
|
|
162
|
+
let tcp;
|
|
147
163
|
try {
|
|
148
|
-
await
|
|
149
|
-
server.once("error", reject);
|
|
150
|
-
server.listen(socketPath, () => {
|
|
151
|
-
server.removeListener("error", reject);
|
|
152
|
-
resolve();
|
|
153
|
-
});
|
|
154
|
-
});
|
|
164
|
+
await listen(server, { path: socketPath });
|
|
155
165
|
fs.chmodSync(socketPath, 0o600);
|
|
166
|
+
if (tcpServer) {
|
|
167
|
+
await listen(tcpServer, { port: 0, host: "127.0.0.1" });
|
|
168
|
+
tcp = { host: "127.0.0.1", port: tcpServer.address().port };
|
|
169
|
+
}
|
|
156
170
|
}
|
|
157
171
|
catch (err) {
|
|
158
172
|
server.close();
|
|
173
|
+
tcpServer?.close();
|
|
159
174
|
liveDirs.delete(dir);
|
|
160
175
|
removeDirSync(dir);
|
|
161
176
|
uninstallCrashHooksIfIdle();
|
|
@@ -167,10 +182,12 @@ export async function startToolBridge(opts) {
|
|
|
167
182
|
return {
|
|
168
183
|
socketPath,
|
|
169
184
|
token,
|
|
185
|
+
tcp,
|
|
186
|
+
egress: tcp ? [`${tcp.host}:${tcp.port}`] : [],
|
|
170
187
|
mcpServer: {
|
|
171
188
|
type: "stdio",
|
|
172
189
|
command: shim.command,
|
|
173
|
-
args: [...shim.args, "--socket", socketPath],
|
|
190
|
+
args: [...shim.args, ...(tcp ? ["--connect", `${tcp.host}:${tcp.port}`] : ["--socket", socketPath])],
|
|
174
191
|
// No update-check nudge in the shim: its stdio belongs to MCP.
|
|
175
192
|
env: { [TOKEN_ENV]: token, SWENY_NO_UPDATE_CHECK: "1" },
|
|
176
193
|
},
|
|
@@ -180,7 +197,7 @@ export async function startToolBridge(opts) {
|
|
|
180
197
|
closed = true;
|
|
181
198
|
for (const s of sockets)
|
|
182
199
|
s.destroy();
|
|
183
|
-
await new Promise((resolve) =>
|
|
200
|
+
await Promise.all([server, tcpServer].map((s) => (s ? new Promise((resolve) => s.close(() => resolve())) : undefined)));
|
|
184
201
|
liveDirs.delete(dir);
|
|
185
202
|
removeDirSync(dir);
|
|
186
203
|
uninstallCrashHooksIfIdle();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path
|
|
2
|
+
* SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`, or
|
|
3
|
+
* `--connect 127.0.0.1:<port>` inside the process sandbox (#439), where it
|
|
4
|
+
* tunnels through the sandbox's HTTP proxy.
|
|
3
5
|
*
|
|
4
6
|
* A stdio MCP server a harness starts as a child process. It holds no tools
|
|
5
7
|
* of its own: `tools/list` and `tools/call` are forwarded over the per-run
|
|
@@ -9,17 +11,49 @@
|
|
|
9
11
|
* `--token` or, by default, the `SWENY_TOOL_BRIDGE_TOKEN` env var the server
|
|
10
12
|
* puts in the MCP server config, so it never shows up in `ps` output.
|
|
11
13
|
*/
|
|
14
|
+
import * as net from "node:net";
|
|
12
15
|
import { type BridgeMethod, type BridgeResponse } from "./protocol.js";
|
|
16
|
+
/** Where the bridge listens: the per-run unix socket, or its loopback TCP port (#439). */
|
|
17
|
+
export type BridgeEndpoint = {
|
|
18
|
+
socketPath: string;
|
|
19
|
+
} | {
|
|
20
|
+
host: string;
|
|
21
|
+
port: number;
|
|
22
|
+
};
|
|
23
|
+
/** Parse `--connect host:port` (IPv4 literal or `localhost`, or `[v6]:port`). */
|
|
24
|
+
export declare function parseConnectTarget(value: string): {
|
|
25
|
+
host: string;
|
|
26
|
+
port: number;
|
|
27
|
+
} | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* The HTTP proxy a sandboxed shim must tunnel through, from its env. srt sets
|
|
30
|
+
* HTTP_PROXY / HTTPS_PROXY (with its per-run credentials) for everything it
|
|
31
|
+
* wraps; inside the sandbox nothing else reaches the host. NO_PROXY is
|
|
32
|
+
* ignored on purpose: srt lists 127.0.0.1 there, but inside the sandbox's own
|
|
33
|
+
* network namespace 127.0.0.1 is not the host.
|
|
34
|
+
*/
|
|
35
|
+
export declare function bridgeProxyFromEnv(env: NodeJS.ProcessEnv): URL | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Open a byte stream to the bridge. A TCP endpoint goes through an HTTP
|
|
38
|
+
* CONNECT tunnel when the env names a proxy (the sandboxed case), else
|
|
39
|
+
* straight to the port. `leftover` holds any bytes read past the proxy's
|
|
40
|
+
* response header.
|
|
41
|
+
*/
|
|
42
|
+
export declare function openBridgeStream(endpoint: BridgeEndpoint, env?: NodeJS.ProcessEnv): Promise<{
|
|
43
|
+
sock: net.Socket;
|
|
44
|
+
leftover: Buffer;
|
|
45
|
+
}>;
|
|
13
46
|
/** Socket client that multiplexes requests by id over one connection. */
|
|
14
47
|
export declare class BridgeClient {
|
|
15
|
-
private readonly socketPath;
|
|
16
48
|
private readonly token;
|
|
49
|
+
private readonly env;
|
|
17
50
|
private sock;
|
|
18
51
|
private connecting;
|
|
19
52
|
private nextId;
|
|
20
53
|
private pending;
|
|
21
54
|
private closedError;
|
|
22
|
-
|
|
55
|
+
private readonly endpoint;
|
|
56
|
+
constructor(endpoint: string | BridgeEndpoint, token: string, env?: NodeJS.ProcessEnv);
|
|
23
57
|
private connect;
|
|
24
58
|
private failAll;
|
|
25
59
|
request(method: BridgeMethod, params?: {
|
|
@@ -29,7 +63,10 @@ export declare class BridgeClient {
|
|
|
29
63
|
close(): void;
|
|
30
64
|
}
|
|
31
65
|
export interface ShimOptions {
|
|
32
|
-
socket
|
|
66
|
+
/** The per-run unix socket. */
|
|
67
|
+
socket?: string;
|
|
68
|
+
/** Or the bridge's loopback TCP endpoint, `host:port` (sandboxed agents, #439). */
|
|
69
|
+
connect?: string;
|
|
33
70
|
/** Falls back to `SWENY_TOOL_BRIDGE_TOKEN`. */
|
|
34
71
|
token?: string;
|
|
35
72
|
version?: string;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path
|
|
2
|
+
* SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`, or
|
|
3
|
+
* `--connect 127.0.0.1:<port>` inside the process sandbox (#439), where it
|
|
4
|
+
* tunnels through the sandbox's HTTP proxy.
|
|
3
5
|
*
|
|
4
6
|
* A stdio MCP server a harness starts as a child process. It holds no tools
|
|
5
7
|
* of its own: `tools/list` and `tools/call` are forwarded over the per-run
|
|
@@ -14,18 +16,116 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
14
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
17
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
16
18
|
import { BRIDGE_PROTOCOL_VERSION, TOKEN_ENV, createFrameReader, encodeFrame, } from "./protocol.js";
|
|
19
|
+
/** Parse `--connect host:port` (IPv4 literal or `localhost`, or `[v6]:port`). */
|
|
20
|
+
export function parseConnectTarget(value) {
|
|
21
|
+
const m = /^(?:\[([0-9a-fA-F:.]+)\]|([A-Za-z0-9.-]+)):([1-9][0-9]{0,4})$/.exec(value);
|
|
22
|
+
if (!m)
|
|
23
|
+
return undefined;
|
|
24
|
+
const port = Number(m[3]);
|
|
25
|
+
return port <= 65535 ? { host: m[1] ?? m[2], port } : undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The HTTP proxy a sandboxed shim must tunnel through, from its env. srt sets
|
|
29
|
+
* HTTP_PROXY / HTTPS_PROXY (with its per-run credentials) for everything it
|
|
30
|
+
* wraps; inside the sandbox nothing else reaches the host. NO_PROXY is
|
|
31
|
+
* ignored on purpose: srt lists 127.0.0.1 there, but inside the sandbox's own
|
|
32
|
+
* network namespace 127.0.0.1 is not the host.
|
|
33
|
+
*/
|
|
34
|
+
export function bridgeProxyFromEnv(env) {
|
|
35
|
+
for (const k of ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) {
|
|
36
|
+
const v = env[k];
|
|
37
|
+
if (!v)
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(v);
|
|
41
|
+
if (u.protocol === "http:")
|
|
42
|
+
return u;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// not a URL: try the next variable
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const MAX_PROXY_RESPONSE_BYTES = 16 * 1024;
|
|
51
|
+
/**
|
|
52
|
+
* Open a byte stream to the bridge. A TCP endpoint goes through an HTTP
|
|
53
|
+
* CONNECT tunnel when the env names a proxy (the sandboxed case), else
|
|
54
|
+
* straight to the port. `leftover` holds any bytes read past the proxy's
|
|
55
|
+
* response header.
|
|
56
|
+
*/
|
|
57
|
+
export function openBridgeStream(endpoint, env = process.env) {
|
|
58
|
+
const direct = (sock) => new Promise((resolve, reject) => {
|
|
59
|
+
sock.once("error", reject);
|
|
60
|
+
sock.once("connect", () => {
|
|
61
|
+
sock.removeListener("error", reject);
|
|
62
|
+
resolve({ sock, leftover: Buffer.alloc(0) });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
if ("socketPath" in endpoint)
|
|
66
|
+
return direct(net.createConnection(endpoint.socketPath));
|
|
67
|
+
const proxy = bridgeProxyFromEnv(env);
|
|
68
|
+
if (!proxy)
|
|
69
|
+
return direct(net.createConnection(endpoint.port, endpoint.host));
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const target = endpoint.host.includes(":")
|
|
72
|
+
? `[${endpoint.host}]:${endpoint.port}`
|
|
73
|
+
: `${endpoint.host}:${endpoint.port}`;
|
|
74
|
+
const sock = net.createConnection(Number(proxy.port || 80), proxy.hostname.replace(/^\[|\]$/g, ""));
|
|
75
|
+
let buf = Buffer.alloc(0);
|
|
76
|
+
let done = false;
|
|
77
|
+
const fail = (err) => {
|
|
78
|
+
if (done)
|
|
79
|
+
return;
|
|
80
|
+
done = true;
|
|
81
|
+
sock.destroy();
|
|
82
|
+
reject(err);
|
|
83
|
+
};
|
|
84
|
+
const onData = (chunk) => {
|
|
85
|
+
buf = Buffer.concat([buf, chunk]);
|
|
86
|
+
const end = buf.indexOf("\r\n\r\n");
|
|
87
|
+
if (end === -1) {
|
|
88
|
+
if (buf.length > MAX_PROXY_RESPONSE_BYTES)
|
|
89
|
+
fail(new Error("tool bridge: malformed proxy response"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const status = buf.subarray(0, buf.indexOf("\r\n")).toString("latin1");
|
|
93
|
+
if (!/^HTTP\/1\.[01] 200(\s|$)/.test(status)) {
|
|
94
|
+
fail(new Error(`tool bridge: the sandbox proxy refused ${target} (${status})`));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
done = true;
|
|
98
|
+
sock.removeListener("data", onData);
|
|
99
|
+
sock.removeListener("error", fail);
|
|
100
|
+
sock.removeListener("close", onClose);
|
|
101
|
+
resolve({ sock, leftover: buf.subarray(end + 4) });
|
|
102
|
+
};
|
|
103
|
+
const onClose = () => fail(new Error("tool bridge: the sandbox proxy closed the connection"));
|
|
104
|
+
sock.on("data", onData);
|
|
105
|
+
sock.once("error", fail);
|
|
106
|
+
sock.once("close", onClose);
|
|
107
|
+
sock.once("connect", () => {
|
|
108
|
+
const user = decodeURIComponent(proxy.username);
|
|
109
|
+
const pass = decodeURIComponent(proxy.password);
|
|
110
|
+
const auth = user || pass ? `Proxy-Authorization: Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}\r\n` : "";
|
|
111
|
+
sock.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n${auth}\r\n`);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
17
115
|
/** Socket client that multiplexes requests by id over one connection. */
|
|
18
116
|
export class BridgeClient {
|
|
19
|
-
socketPath;
|
|
20
117
|
token;
|
|
118
|
+
env;
|
|
21
119
|
sock;
|
|
22
120
|
connecting;
|
|
23
121
|
nextId = 1;
|
|
24
122
|
pending = new Map();
|
|
25
123
|
closedError;
|
|
26
|
-
|
|
27
|
-
|
|
124
|
+
endpoint;
|
|
125
|
+
constructor(endpoint, token, env = process.env) {
|
|
28
126
|
this.token = token;
|
|
127
|
+
this.env = env;
|
|
128
|
+
this.endpoint = typeof endpoint === "string" ? { socketPath: endpoint } : endpoint;
|
|
29
129
|
}
|
|
30
130
|
connect() {
|
|
31
131
|
// Once the connection is gone (bad token, server closed) every later call fails fast.
|
|
@@ -33,8 +133,7 @@ export class BridgeClient {
|
|
|
33
133
|
return Promise.reject(this.closedError);
|
|
34
134
|
if (this.sock)
|
|
35
135
|
return Promise.resolve(this.sock);
|
|
36
|
-
this.connecting ??=
|
|
37
|
-
const sock = net.createConnection(this.socketPath);
|
|
136
|
+
this.connecting ??= openBridgeStream(this.endpoint, this.env).then(({ sock, leftover }) => {
|
|
38
137
|
const read = createFrameReader((value) => {
|
|
39
138
|
const res = value;
|
|
40
139
|
if (!res || typeof res.id !== "number")
|
|
@@ -50,15 +149,15 @@ export class BridgeClient {
|
|
|
50
149
|
}
|
|
51
150
|
}, () => this.failAll(new Error("tool bridge: response frame too large")));
|
|
52
151
|
sock.on("data", read);
|
|
53
|
-
sock.once("
|
|
54
|
-
this.sock = sock;
|
|
55
|
-
resolve(sock);
|
|
56
|
-
});
|
|
57
|
-
sock.once("error", (err) => {
|
|
58
|
-
reject(err);
|
|
59
|
-
this.failAll(err);
|
|
60
|
-
});
|
|
152
|
+
sock.once("error", (err) => this.failAll(err));
|
|
61
153
|
sock.once("close", () => this.failAll(new Error("tool bridge: connection closed")));
|
|
154
|
+
this.sock = sock;
|
|
155
|
+
if (leftover.length > 0)
|
|
156
|
+
read(leftover);
|
|
157
|
+
return sock;
|
|
158
|
+
}, (err) => {
|
|
159
|
+
this.failAll(err);
|
|
160
|
+
throw err;
|
|
62
161
|
});
|
|
63
162
|
return this.connecting;
|
|
64
163
|
}
|
|
@@ -89,13 +188,15 @@ export class BridgeClient {
|
|
|
89
188
|
/** Run the stdio MCP shim until stdin closes or the bridge goes away. */
|
|
90
189
|
export async function runToolBridgeShim(opts) {
|
|
91
190
|
const token = opts.token ?? process.env[TOKEN_ENV];
|
|
92
|
-
|
|
93
|
-
|
|
191
|
+
const tcp = opts.connect ? parseConnectTarget(opts.connect) : undefined;
|
|
192
|
+
const endpoint = opts.socket ? { socketPath: opts.socket } : tcp;
|
|
193
|
+
if (!endpoint || (opts.socket && opts.connect) || (opts.connect && !tcp) || !token) {
|
|
194
|
+
process.stderr.write(`sweny tool-bridge: one of --socket <path> or --connect <host:port>, and a token (--token or ${TOKEN_ENV}), are required\n`);
|
|
94
195
|
process.exit(2);
|
|
95
196
|
}
|
|
96
197
|
// Keep the token out of anything the shim might spawn or report.
|
|
97
198
|
delete process.env[TOKEN_ENV];
|
|
98
|
-
const client = new BridgeClient(
|
|
199
|
+
const client = new BridgeClient(endpoint, token);
|
|
99
200
|
const server = new Server({ name: "sweny-core", version: opts.version ?? "0.0.0" }, { capabilities: { tools: {} } });
|
|
100
201
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
101
202
|
const res = await client.request("tools/list");
|
package/dist/safe-outputs.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the write, so this holds on every harness.
|
|
10
10
|
*
|
|
11
11
|
* Order of checks per intent (all deterministic): declared type, workflow
|
|
12
|
-
* ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
|
|
12
|
+
* ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
|
|
13
13
|
* labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
|
|
14
14
|
* over the intents that passed and can only veto them.
|
|
15
15
|
*/
|
|
@@ -31,6 +31,8 @@ export interface SafeOutputIntent {
|
|
|
31
31
|
labels?: string[];
|
|
32
32
|
head?: string;
|
|
33
33
|
base?: string;
|
|
34
|
+
/** issue_state: `reopen` or `close`. */
|
|
35
|
+
state?: string;
|
|
34
36
|
dedupe_key?: string;
|
|
35
37
|
/** ms since epoch when the intent was recorded. */
|
|
36
38
|
recordedAt: number;
|
package/dist/safe-outputs.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the write, so this holds on every harness.
|
|
10
10
|
*
|
|
11
11
|
* Order of checks per intent (all deterministic): declared type, workflow
|
|
12
|
-
* ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
|
|
12
|
+
* ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
|
|
13
13
|
* labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
|
|
14
14
|
* over the intents that passed and can only veto them.
|
|
15
15
|
*/
|
|
@@ -49,7 +49,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
|
|
|
49
49
|
return {
|
|
50
50
|
name: EMIT_OUTPUT_TOOL,
|
|
51
51
|
access: "read",
|
|
52
|
-
description: "Request a write (comment, issue, pr or
|
|
52
|
+
description: "Request a write (comment, issue, pr, label or issue_state). This does NOT write anything now: sweny checks the request " +
|
|
53
53
|
"against this step's declared outputs and limits, and applies it after the step finishes. Call once per write. " +
|
|
54
54
|
`Allowed types here: ${types.join(", ")}.`,
|
|
55
55
|
input_schema: {
|
|
@@ -64,8 +64,9 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
|
|
|
64
64
|
},
|
|
65
65
|
number: {
|
|
66
66
|
type: "string",
|
|
67
|
-
description: "Issue or PR number (GitHub) or issue id (Linear), for comment and
|
|
67
|
+
description: "Issue or PR number (GitHub) or issue id (Linear), for comment, label and issue_state",
|
|
68
68
|
},
|
|
69
|
+
state: { type: "string", enum: ["reopen", "close"], description: "issue_state only: reopen or close" },
|
|
69
70
|
labels: { type: "array", items: { type: "string" }, description: "Labels to add" },
|
|
70
71
|
head: { type: "string", description: "pr only: branch with the changes" },
|
|
71
72
|
base: { type: "string", description: "pr only: target branch (default main)" },
|
|
@@ -88,7 +89,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
|
|
|
88
89
|
return { recorded: false, error: `limit reached: at most ${max} ${type} write(s) from this step` };
|
|
89
90
|
}
|
|
90
91
|
const intent = { type, recordedAt: now() };
|
|
91
|
-
const fields = ["title", "body", "target", "number", "head", "base", "dedupe_key"];
|
|
92
|
+
const fields = ["title", "body", "target", "number", "head", "base", "state", "dedupe_key"];
|
|
92
93
|
for (const f of fields) {
|
|
93
94
|
const v = str(input?.[f]);
|
|
94
95
|
if (v !== undefined)
|
|
@@ -117,6 +118,8 @@ export function safeOutputsInstruction(declarations, input) {
|
|
|
117
118
|
const pin = resolvePin(d.number, input);
|
|
118
119
|
parts.push(pin ? `only issue or PR ${pin} (the number may be omitted)` : "pinned issue not set for this run");
|
|
119
120
|
}
|
|
121
|
+
if (d.type === "issue_state")
|
|
122
|
+
parts.push(d.state ? `only to ${d.state}` : "reopen or close");
|
|
120
123
|
if (d.title_prefix)
|
|
121
124
|
parts.push(`title prefix "${d.title_prefix}"`);
|
|
122
125
|
if (d.type === "label" && d.labels?.length)
|
|
@@ -247,6 +250,11 @@ const APPLIERS = {
|
|
|
247
250
|
needsTarget: true,
|
|
248
251
|
build: (i) => ({ repo: i.target, issue_number: Number(i.number), labels: i.labels }),
|
|
249
252
|
},
|
|
253
|
+
issue_state: {
|
|
254
|
+
tool: "github_set_issue_state",
|
|
255
|
+
needsTarget: true,
|
|
256
|
+
build: (i) => ({ repo: i.target, issue_number: Number(i.number), state: i.state }),
|
|
257
|
+
},
|
|
250
258
|
},
|
|
251
259
|
linear: {
|
|
252
260
|
comment: {
|
|
@@ -264,6 +272,11 @@ const APPLIERS = {
|
|
|
264
272
|
...(i.labels ? { labelIds: i.labels } : {}),
|
|
265
273
|
}),
|
|
266
274
|
},
|
|
275
|
+
issue_state: {
|
|
276
|
+
tool: "linear_set_issue_state",
|
|
277
|
+
needsTarget: false,
|
|
278
|
+
build: (i) => ({ issueId: i.number, state: i.state }),
|
|
279
|
+
},
|
|
267
280
|
},
|
|
268
281
|
};
|
|
269
282
|
function dedupeKey(w, explicit) {
|
|
@@ -279,6 +292,7 @@ function dedupeKey(w, explicit) {
|
|
|
279
292
|
[...(w.labels ?? [])].sort().join(","),
|
|
280
293
|
w.head ?? "",
|
|
281
294
|
w.base ?? "",
|
|
295
|
+
...(w.state ? [w.state] : []),
|
|
282
296
|
];
|
|
283
297
|
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
284
298
|
}
|
|
@@ -377,9 +391,11 @@ export async function applySafeOutputs(o) {
|
|
|
377
391
|
refuse(type === "comment" && !body.trim() ? "missing body" : "body too long", at);
|
|
378
392
|
continue;
|
|
379
393
|
}
|
|
380
|
-
// Issue pin: a pinned comment or
|
|
394
|
+
// Issue pin: a pinned comment, label or state change may only land on that issue or PR.
|
|
395
|
+
// issue_state never guesses an issue: it needs the pin or a number on the intent.
|
|
396
|
+
const onIssue = type === "comment" || type === "label" || type === "issue_state";
|
|
381
397
|
let issueRef = intent.number?.trim().replace(/^#/, "") || undefined;
|
|
382
|
-
if (
|
|
398
|
+
if (onIssue && decl.number !== undefined) {
|
|
383
399
|
const pin = resolvePin(decl.number, o.input);
|
|
384
400
|
if (!pin) {
|
|
385
401
|
refuse("pinned issue is not set for this run", at);
|
|
@@ -391,10 +407,29 @@ export async function applySafeOutputs(o) {
|
|
|
391
407
|
}
|
|
392
408
|
issueRef = pin;
|
|
393
409
|
}
|
|
394
|
-
if (
|
|
410
|
+
if (onIssue && !issueRef) {
|
|
395
411
|
refuse("missing number", at);
|
|
396
412
|
continue;
|
|
397
413
|
}
|
|
414
|
+
// State change: reopen or close, inside the declared direction.
|
|
415
|
+
let state;
|
|
416
|
+
if (type === "issue_state") {
|
|
417
|
+
const asked = intent.state?.trim().toLowerCase() || decl.state;
|
|
418
|
+
if (asked !== "reopen" && asked !== "close") {
|
|
419
|
+
refuse("state must be reopen or close", at);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (decl.state && asked !== decl.state) {
|
|
423
|
+
refuse("state outside the declared state", at);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
// Closing needs the declaration to pin the issue (a workflow that skipped validation cannot bypass it).
|
|
427
|
+
if (asked === "close" && decl.number === undefined) {
|
|
428
|
+
refuse("close needs a pinned issue", at);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
state = asked;
|
|
432
|
+
}
|
|
398
433
|
if (via === "github" && issueRef !== undefined && !/^[1-9][0-9]*$/.test(issueRef)) {
|
|
399
434
|
refuse("number must be an issue or PR number", at);
|
|
400
435
|
continue;
|
|
@@ -432,11 +467,11 @@ export async function applySafeOutputs(o) {
|
|
|
432
467
|
refuse("title too long", at);
|
|
433
468
|
continue;
|
|
434
469
|
}
|
|
435
|
-
// A Linear comment's issue ID does not encode or enforce its declared team.
|
|
470
|
+
// A Linear comment's (or state change's) issue ID does not encode or enforce its declared team.
|
|
436
471
|
// Resolve it through the configured read tool and carry its immutable ID to
|
|
437
472
|
// the write. Missing lookup support or unverifiable membership fails closed.
|
|
438
473
|
let number = issueRef;
|
|
439
|
-
if (via === "linear" && type === "comment" && target) {
|
|
474
|
+
if (via === "linear" && (type === "comment" || type === "issue_state") && target) {
|
|
440
475
|
const lookup = o.skills.get(via)?.tools.find((t) => t.name === "linear_get_issue" && t.access === "read");
|
|
441
476
|
let issue;
|
|
442
477
|
try {
|
|
@@ -464,7 +499,8 @@ export async function applySafeOutputs(o) {
|
|
|
464
499
|
...(target ? { target } : {}),
|
|
465
500
|
...(number !== undefined ? { number } : {}),
|
|
466
501
|
...(title ? { title } : {}),
|
|
467
|
-
...(type
|
|
502
|
+
...(type === "comment" || type === "issue" || type === "pr" ? { body } : {}),
|
|
503
|
+
...(state ? { state } : {}),
|
|
468
504
|
...(labels ? { labels } : {}),
|
|
469
505
|
...(type === "pr" ? { head: intent.head, ...(intent.base ? { base: intent.base } : {}) } : {}),
|
|
470
506
|
};
|
|
@@ -546,6 +582,8 @@ export async function applySafeOutputs(o) {
|
|
|
546
582
|
function printPreview(w, logger) {
|
|
547
583
|
const where = `${w.target ?? ""}${w.number ? `#${w.number}` : ""}`;
|
|
548
584
|
logger.info(` [staged] ${w.type} via ${w.via}${where ? ` -> ${where}` : ""}${w.title ? `: ${w.title}` : ""}`);
|
|
585
|
+
if (w.state)
|
|
586
|
+
logger.info(` state: ${w.state}`);
|
|
549
587
|
if (w.labels?.length)
|
|
550
588
|
logger.info(` labels: ${w.labels.join(", ")}`);
|
|
551
589
|
if (w.type === "pr")
|