@ai-sdk/harness-opencode 1.0.27 → 1.0.28
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/CHANGELOG.md +6 -0
- package/dist/bridge/index.mjs +131 -136
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/bridge/index.ts +2 -101
- package/src/bridge/tool-relay-auth.ts +59 -86
- package/src/bridge/tool-relay.ts +107 -0
package/dist/index.js
CHANGED
|
@@ -168,7 +168,7 @@ var inboundMessageSchema = z.discriminatedUnion("type", [
|
|
|
168
168
|
]);
|
|
169
169
|
|
|
170
170
|
// src/version.ts
|
|
171
|
-
var VERSION = true ? "1.0.
|
|
171
|
+
var VERSION = true ? "1.0.28" : "0.0.0-test";
|
|
172
172
|
|
|
173
173
|
// src/opencode-harness.ts
|
|
174
174
|
var OPENCODE_CLIENT_APP = `ai-sdk/harness-opencode/${VERSION}`;
|
package/package.json
CHANGED
package/src/bridge/index.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
} from '@ai-sdk/harness/bridge';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
import { mkdirSync } from 'node:fs';
|
|
8
|
-
import { createServer, type Server } from 'node:http';
|
|
9
8
|
import path from 'node:path';
|
|
10
9
|
import { argv, env as procEnv } from 'node:process';
|
|
11
10
|
import type { StartMessage } from '../opencode-bridge-protocol';
|
|
@@ -35,21 +34,12 @@ import {
|
|
|
35
34
|
type HarnessUsage,
|
|
36
35
|
type OpenCodeTokenUsage,
|
|
37
36
|
} from './opencode-usage';
|
|
38
|
-
import {
|
|
39
|
-
ToolRelayAuthorizer,
|
|
40
|
-
isToolRelayRequestFromAllowedProcess,
|
|
41
|
-
type ToolRelayCall,
|
|
42
|
-
} from './tool-relay-auth';
|
|
37
|
+
import { startAuthorizedToolRelay, type ToolRelay } from './tool-relay';
|
|
43
38
|
|
|
44
39
|
type Emit = (msg: Record<string, unknown>) => void;
|
|
45
40
|
|
|
46
41
|
type OpenCodeClient = ReturnType<typeof createOpencodeClient>;
|
|
47
42
|
type OpenCodeServer = Awaited<ReturnType<typeof createOpencodeServer>>;
|
|
48
|
-
type ToolRelay = {
|
|
49
|
-
port: number;
|
|
50
|
-
close(): void;
|
|
51
|
-
authorizeToolCall(call: ToolRelayCall): void;
|
|
52
|
-
};
|
|
53
43
|
|
|
54
44
|
type RuntimeState = {
|
|
55
45
|
server?: OpenCodeServer;
|
|
@@ -172,7 +162,6 @@ async function ensureRuntime({
|
|
|
172
162
|
if (start.tools && start.tools.length > 0) {
|
|
173
163
|
runtime.toolNames = new Set(start.tools.map(tool => tool.name));
|
|
174
164
|
runtime.relay = await startToolRelay({
|
|
175
|
-
allowedScriptPaths: [`${bootstrapDir}/host-tool-mcp.mjs`],
|
|
176
165
|
tools: start.tools,
|
|
177
166
|
emit,
|
|
178
167
|
requestToolResult: turn.requestToolResult,
|
|
@@ -1814,105 +1803,17 @@ function emitAssistantContentPart(part: unknown, emit: Emit): void {
|
|
|
1814
1803
|
}
|
|
1815
1804
|
|
|
1816
1805
|
async function startToolRelay({
|
|
1817
|
-
allowedScriptPaths,
|
|
1818
1806
|
tools,
|
|
1819
1807
|
emit,
|
|
1820
1808
|
requestToolResult,
|
|
1821
1809
|
}: {
|
|
1822
|
-
allowedScriptPaths: ReadonlyArray<string>;
|
|
1823
1810
|
tools: ReadonlyArray<{ name: string }>;
|
|
1824
1811
|
emit: Emit;
|
|
1825
1812
|
requestToolResult: (
|
|
1826
1813
|
toolCallId: string,
|
|
1827
1814
|
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
1828
1815
|
}): Promise<ToolRelay> {
|
|
1829
|
-
|
|
1830
|
-
const allowedScriptPathSet = new Set(allowedScriptPaths);
|
|
1831
|
-
const authorizer = new ToolRelayAuthorizer();
|
|
1832
|
-
const server = createServer(async (req, res) => {
|
|
1833
|
-
try {
|
|
1834
|
-
if (req.method !== 'POST' || req.url !== '/') {
|
|
1835
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1836
|
-
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
const chunks: Buffer[] = [];
|
|
1840
|
-
for await (const chunk of req) {
|
|
1841
|
-
chunks.push(chunk as Buffer);
|
|
1842
|
-
}
|
|
1843
|
-
const body = Buffer.concat(chunks).toString('utf8');
|
|
1844
|
-
const { requestId, toolName, input } = JSON.parse(body) as {
|
|
1845
|
-
requestId: string;
|
|
1846
|
-
toolName: string;
|
|
1847
|
-
input: unknown;
|
|
1848
|
-
};
|
|
1849
|
-
|
|
1850
|
-
if (!toolNames.has(toolName)) {
|
|
1851
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1852
|
-
res.end(
|
|
1853
|
-
JSON.stringify({ error: `Tool "${toolName}" is not available` }),
|
|
1854
|
-
);
|
|
1855
|
-
return;
|
|
1856
|
-
}
|
|
1857
|
-
const authorized =
|
|
1858
|
-
authorizer.consumeToolCall({ toolName, input }) ||
|
|
1859
|
-
(await isToolRelayRequestFromAllowedProcess({
|
|
1860
|
-
socket: req.socket,
|
|
1861
|
-
allowedScriptPaths: allowedScriptPathSet,
|
|
1862
|
-
}));
|
|
1863
|
-
if (!authorized) {
|
|
1864
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1865
|
-
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1866
|
-
return;
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
emit({
|
|
1870
|
-
type: 'tool-call',
|
|
1871
|
-
toolCallId: requestId,
|
|
1872
|
-
toolName,
|
|
1873
|
-
input: JSON.stringify(input ?? {}),
|
|
1874
|
-
providerExecuted: false,
|
|
1875
|
-
});
|
|
1876
|
-
|
|
1877
|
-
const { output, isError } = await requestToolResult(requestId);
|
|
1878
|
-
emit({
|
|
1879
|
-
type: 'tool-result',
|
|
1880
|
-
toolCallId: requestId,
|
|
1881
|
-
toolName,
|
|
1882
|
-
result: output ?? null,
|
|
1883
|
-
isError: !!isError,
|
|
1884
|
-
});
|
|
1885
|
-
|
|
1886
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1887
|
-
res.end(JSON.stringify({ result: output }));
|
|
1888
|
-
} catch (error) {
|
|
1889
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1890
|
-
res.end(
|
|
1891
|
-
JSON.stringify({
|
|
1892
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1893
|
-
}),
|
|
1894
|
-
);
|
|
1895
|
-
}
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
await new Promise<void>(resolve =>
|
|
1899
|
-
server.listen(0, '127.0.0.1', () => resolve()),
|
|
1900
|
-
);
|
|
1901
|
-
const address = server.address();
|
|
1902
|
-
if (!address || typeof address === 'string') {
|
|
1903
|
-
throw new Error('tool relay did not expose a numeric port');
|
|
1904
|
-
}
|
|
1905
|
-
return {
|
|
1906
|
-
port: address.port,
|
|
1907
|
-
close: () => closeServer(server),
|
|
1908
|
-
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
1909
|
-
};
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
function closeServer(server: Server): void {
|
|
1913
|
-
try {
|
|
1914
|
-
server.close();
|
|
1915
|
-
} catch {}
|
|
1816
|
+
return startAuthorizedToolRelay({ tools, emit, requestToolResult });
|
|
1916
1817
|
}
|
|
1917
1818
|
|
|
1918
1819
|
function createDeferred<T>(): {
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
2
|
-
import type { Socket } from 'node:net';
|
|
3
|
-
|
|
4
1
|
export type ToolRelayCall = {
|
|
5
2
|
toolName: string;
|
|
6
3
|
input: unknown;
|
|
@@ -11,6 +8,12 @@ export class ToolRelayAuthorizer {
|
|
|
11
8
|
private readonly now: () => number;
|
|
12
9
|
private readonly authorizations: Array<{ key: string; expiresAt: number }> =
|
|
13
10
|
[];
|
|
11
|
+
private readonly pendingRequests: Array<{
|
|
12
|
+
key: string;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
15
|
+
resolve: (authorized: boolean) => void;
|
|
16
|
+
}> = [];
|
|
14
17
|
|
|
15
18
|
constructor({
|
|
16
19
|
ttlMs = 10_000,
|
|
@@ -25,19 +28,58 @@ export class ToolRelayAuthorizer {
|
|
|
25
28
|
|
|
26
29
|
authorizeToolCall(call: ToolRelayCall): void {
|
|
27
30
|
this.pruneExpired();
|
|
31
|
+
const key = toolRelayCallKey(call);
|
|
32
|
+
const pendingRequestIndex = this.pendingRequests.findIndex(
|
|
33
|
+
request => request.key === key,
|
|
34
|
+
);
|
|
35
|
+
if (pendingRequestIndex !== -1) {
|
|
36
|
+
const [pendingRequest] = this.pendingRequests.splice(
|
|
37
|
+
pendingRequestIndex,
|
|
38
|
+
1,
|
|
39
|
+
);
|
|
40
|
+
clearTimeout(pendingRequest.timeout);
|
|
41
|
+
pendingRequest.resolve(true);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
28
44
|
this.authorizations.push({
|
|
29
|
-
key
|
|
45
|
+
key,
|
|
30
46
|
expiresAt: this.now() + this.ttlMs,
|
|
31
47
|
});
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
|
|
50
|
+
waitForToolCallAuthorization(call: ToolRelayCall): Promise<boolean> {
|
|
35
51
|
this.pruneExpired();
|
|
36
52
|
const key = toolRelayCallKey(call);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
const authorizationIndex = this.authorizations.findIndex(
|
|
54
|
+
authorization => authorization.key === key,
|
|
55
|
+
);
|
|
56
|
+
if (authorizationIndex !== -1) {
|
|
57
|
+
this.authorizations.splice(authorizationIndex, 1);
|
|
58
|
+
return Promise.resolve(true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const expiresAt = this.now() + this.ttlMs;
|
|
62
|
+
return new Promise(resolve => {
|
|
63
|
+
const pendingRequest = {
|
|
64
|
+
key,
|
|
65
|
+
expiresAt,
|
|
66
|
+
timeout: setTimeout(() => {
|
|
67
|
+
const index = this.pendingRequests.indexOf(pendingRequest);
|
|
68
|
+
if (index !== -1) this.pendingRequests.splice(index, 1);
|
|
69
|
+
resolve(false);
|
|
70
|
+
}, this.ttlMs),
|
|
71
|
+
resolve,
|
|
72
|
+
};
|
|
73
|
+
this.pendingRequests.push(pendingRequest);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
close(): void {
|
|
78
|
+
this.authorizations.length = 0;
|
|
79
|
+
for (const pendingRequest of this.pendingRequests.splice(0)) {
|
|
80
|
+
clearTimeout(pendingRequest.timeout);
|
|
81
|
+
pendingRequest.resolve(false);
|
|
82
|
+
}
|
|
41
83
|
}
|
|
42
84
|
|
|
43
85
|
private pruneExpired(): void {
|
|
@@ -47,6 +89,14 @@ export class ToolRelayAuthorizer {
|
|
|
47
89
|
this.authorizations.splice(i, 1);
|
|
48
90
|
}
|
|
49
91
|
}
|
|
92
|
+
for (let i = this.pendingRequests.length - 1; i >= 0; i--) {
|
|
93
|
+
const pendingRequest = this.pendingRequests[i];
|
|
94
|
+
if (pendingRequest.expiresAt <= now) {
|
|
95
|
+
this.pendingRequests.splice(i, 1);
|
|
96
|
+
clearTimeout(pendingRequest.timeout);
|
|
97
|
+
pendingRequest.resolve(false);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
50
100
|
}
|
|
51
101
|
}
|
|
52
102
|
|
|
@@ -72,80 +122,3 @@ function normalizeJsonValue(value: unknown): unknown {
|
|
|
72
122
|
}
|
|
73
123
|
return value;
|
|
74
124
|
}
|
|
75
|
-
|
|
76
|
-
export async function isToolRelayRequestFromAllowedProcess({
|
|
77
|
-
socket,
|
|
78
|
-
allowedScriptPaths,
|
|
79
|
-
}: {
|
|
80
|
-
socket: Socket;
|
|
81
|
-
allowedScriptPaths: ReadonlySet<string>;
|
|
82
|
-
}): Promise<boolean> {
|
|
83
|
-
if (process.platform !== 'linux') return false;
|
|
84
|
-
if (!socket.remotePort || !socket.localPort) return false;
|
|
85
|
-
|
|
86
|
-
const inode = await findTcpSocketInode({
|
|
87
|
-
clientPort: socket.remotePort,
|
|
88
|
-
serverPort: socket.localPort,
|
|
89
|
-
});
|
|
90
|
-
if (!inode) return false;
|
|
91
|
-
|
|
92
|
-
const cmdline = await findProcessCmdlineForSocketInode({ inode });
|
|
93
|
-
return cmdline?.some(arg => allowedScriptPaths.has(arg)) ?? false;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function findTcpSocketInode({
|
|
97
|
-
clientPort,
|
|
98
|
-
serverPort,
|
|
99
|
-
}: {
|
|
100
|
-
clientPort: number;
|
|
101
|
-
serverPort: number;
|
|
102
|
-
}): Promise<string | undefined> {
|
|
103
|
-
for (const tablePath of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
104
|
-
const table = await readFile(tablePath, 'utf8').catch(() => undefined);
|
|
105
|
-
if (!table) continue;
|
|
106
|
-
for (const line of table.split('\n').slice(1)) {
|
|
107
|
-
const columns = line.trim().split(/\s+/);
|
|
108
|
-
if (columns.length < 10) continue;
|
|
109
|
-
const local = parseProcNetAddress(columns[1]);
|
|
110
|
-
const remote = parseProcNetAddress(columns[2]);
|
|
111
|
-
if (
|
|
112
|
-
local?.port === clientPort &&
|
|
113
|
-
remote?.port === serverPort &&
|
|
114
|
-
columns[9] !== '0'
|
|
115
|
-
) {
|
|
116
|
-
return columns[9];
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function parseProcNetAddress(value: string): { port: number } | undefined {
|
|
124
|
-
const [, portHex] = value.split(':');
|
|
125
|
-
if (!portHex) return undefined;
|
|
126
|
-
return { port: Number.parseInt(portHex, 16) };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function findProcessCmdlineForSocketInode({
|
|
130
|
-
inode,
|
|
131
|
-
}: {
|
|
132
|
-
inode: string;
|
|
133
|
-
}): Promise<string[] | undefined> {
|
|
134
|
-
const procEntries = await readdir('/proc', { withFileTypes: true }).catch(
|
|
135
|
-
() => [],
|
|
136
|
-
);
|
|
137
|
-
for (const entry of procEntries) {
|
|
138
|
-
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
139
|
-
const fdDir = `/proc/${entry.name}/fd`;
|
|
140
|
-
const fds = await readdir(fdDir).catch(() => []);
|
|
141
|
-
for (const fd of fds) {
|
|
142
|
-
const target = await readlink(`${fdDir}/${fd}`).catch(() => undefined);
|
|
143
|
-
if (target !== `socket:[${inode}]`) continue;
|
|
144
|
-
const cmdline = await readFile(`/proc/${entry.name}/cmdline`, 'utf8')
|
|
145
|
-
.then(value => value.split('\0').filter(Boolean))
|
|
146
|
-
.catch(() => undefined);
|
|
147
|
-
if (cmdline) return cmdline;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return undefined;
|
|
151
|
-
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import { ToolRelayAuthorizer, type ToolRelayCall } from './tool-relay-auth';
|
|
3
|
+
|
|
4
|
+
export type ToolRelay = {
|
|
5
|
+
port: number;
|
|
6
|
+
close(): void;
|
|
7
|
+
authorizeToolCall(call: ToolRelayCall): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export async function startAuthorizedToolRelay({
|
|
11
|
+
tools,
|
|
12
|
+
emit,
|
|
13
|
+
requestToolResult,
|
|
14
|
+
authorizer = new ToolRelayAuthorizer(),
|
|
15
|
+
}: {
|
|
16
|
+
tools: ReadonlyArray<{ name: string }>;
|
|
17
|
+
emit: (message: Record<string, unknown>) => void;
|
|
18
|
+
requestToolResult: (
|
|
19
|
+
toolCallId: string,
|
|
20
|
+
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
21
|
+
authorizer?: ToolRelayAuthorizer;
|
|
22
|
+
}): Promise<ToolRelay> {
|
|
23
|
+
const toolNames = new Set(tools.map(tool => tool.name));
|
|
24
|
+
const server = createServer(async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
if (req.method !== 'POST' || req.url !== '/') {
|
|
27
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
28
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const chunks: Buffer[] = [];
|
|
32
|
+
for await (const chunk of req) {
|
|
33
|
+
chunks.push(chunk as Buffer);
|
|
34
|
+
}
|
|
35
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
36
|
+
const { requestId, toolName, input } = JSON.parse(body) as {
|
|
37
|
+
requestId: string;
|
|
38
|
+
toolName: string;
|
|
39
|
+
input: unknown;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (!toolNames.has(toolName)) {
|
|
43
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
44
|
+
res.end(
|
|
45
|
+
JSON.stringify({ error: `Tool "${toolName}" is not available` }),
|
|
46
|
+
);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
!(await authorizer.waitForToolCallAuthorization({ toolName, input }))
|
|
51
|
+
) {
|
|
52
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
53
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
emit({
|
|
58
|
+
type: 'tool-call',
|
|
59
|
+
toolCallId: requestId,
|
|
60
|
+
toolName,
|
|
61
|
+
input: JSON.stringify(input ?? {}),
|
|
62
|
+
providerExecuted: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const { output, isError } = await requestToolResult(requestId);
|
|
66
|
+
emit({
|
|
67
|
+
type: 'tool-result',
|
|
68
|
+
toolCallId: requestId,
|
|
69
|
+
toolName,
|
|
70
|
+
result: output ?? null,
|
|
71
|
+
isError: !!isError,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
75
|
+
res.end(JSON.stringify({ result: output }));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
78
|
+
res.end(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
error: error instanceof Error ? error.message : String(error),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await new Promise<void>(resolve =>
|
|
87
|
+
server.listen(0, '127.0.0.1', () => resolve()),
|
|
88
|
+
);
|
|
89
|
+
const address = server.address();
|
|
90
|
+
if (!address || typeof address === 'string') {
|
|
91
|
+
throw new Error('tool relay did not expose a numeric port');
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
port: address.port,
|
|
95
|
+
close: () => {
|
|
96
|
+
authorizer.close();
|
|
97
|
+
closeServer(server);
|
|
98
|
+
},
|
|
99
|
+
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function closeServer(server: Server): void {
|
|
104
|
+
try {
|
|
105
|
+
server.close();
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|