@ai-sdk/harness-opencode 1.0.9 → 1.0.11
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 +14 -0
- package/dist/bridge/host-tool-mcp.mjs +1 -3
- package/dist/bridge/host-tool-mcp.mjs.map +1 -1
- package/dist/bridge/index.mjs +167 -19
- package/dist/bridge/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/bridge/host-tool-mcp.ts +0 -2
- package/src/bridge/index.ts +80 -25
- package/src/bridge/tool-relay-auth.ts +151 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness-opencode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"ws": "8.21.0",
|
|
30
|
-
"@ai-sdk/harness": "1.0.
|
|
31
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
30
|
+
"@ai-sdk/harness": "1.0.11",
|
|
31
|
+
"@ai-sdk/provider-utils": "5.0.2"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"zod": "^3.25.76 || ^4.1.8"
|
|
@@ -32,7 +32,6 @@ type ZodShape = Record<string, z.ZodTypeAny>;
|
|
|
32
32
|
|
|
33
33
|
const schemas: ToolSchema[] = JSON.parse(process.env.TOOL_SCHEMAS || '[]');
|
|
34
34
|
const relayUrl = process.env.TOOL_RELAY_URL || '';
|
|
35
|
-
const relayToken = process.env.TOOL_RELAY_TOKEN || '';
|
|
36
35
|
|
|
37
36
|
if (!schemas.length || !relayUrl) {
|
|
38
37
|
process.stderr.write(
|
|
@@ -56,7 +55,6 @@ for (const schema of schemas) {
|
|
|
56
55
|
method: 'POST',
|
|
57
56
|
headers: {
|
|
58
57
|
'Content-Type': 'application/json',
|
|
59
|
-
...(relayToken ? { Authorization: `Bearer ${relayToken}` } : {}),
|
|
60
58
|
},
|
|
61
59
|
body: JSON.stringify({ requestId, toolName: schema.name, input }),
|
|
62
60
|
});
|
package/src/bridge/index.ts
CHANGED
|
@@ -32,17 +32,27 @@ import {
|
|
|
32
32
|
type HarnessUsage,
|
|
33
33
|
type OpenCodeTokenUsage,
|
|
34
34
|
} from './opencode-usage';
|
|
35
|
+
import {
|
|
36
|
+
ToolRelayAuthorizer,
|
|
37
|
+
isToolRelayRequestFromAllowedProcess,
|
|
38
|
+
type ToolRelayCall,
|
|
39
|
+
} from './tool-relay-auth';
|
|
35
40
|
|
|
36
41
|
type Emit = (msg: Record<string, unknown>) => void;
|
|
37
42
|
|
|
38
43
|
type OpenCodeClient = ReturnType<typeof createOpencodeClient>;
|
|
39
44
|
type OpenCodeServer = Awaited<ReturnType<typeof createOpencodeServer>>;
|
|
45
|
+
type ToolRelay = {
|
|
46
|
+
port: number;
|
|
47
|
+
close(): void;
|
|
48
|
+
authorizeToolCall(call: ToolRelayCall): void;
|
|
49
|
+
};
|
|
40
50
|
|
|
41
51
|
type RuntimeState = {
|
|
42
52
|
server?: OpenCodeServer;
|
|
43
53
|
client?: OpenCodeClient;
|
|
44
54
|
sessionId?: string;
|
|
45
|
-
relay?:
|
|
55
|
+
relay?: ToolRelay;
|
|
46
56
|
toolNames: Set<string>;
|
|
47
57
|
};
|
|
48
58
|
|
|
@@ -133,12 +143,10 @@ async function ensureRuntime({
|
|
|
133
143
|
}): Promise<void> {
|
|
134
144
|
if (runtime.client) return;
|
|
135
145
|
|
|
136
|
-
let relayToken: string | undefined;
|
|
137
146
|
if (start.tools && start.tools.length > 0) {
|
|
138
|
-
relayToken = randomUUID();
|
|
139
147
|
runtime.toolNames = new Set(start.tools.map(tool => tool.name));
|
|
140
148
|
runtime.relay = await startToolRelay({
|
|
141
|
-
|
|
149
|
+
allowedScriptPaths: [`${bootstrapDir}/host-tool-mcp.mjs`],
|
|
142
150
|
tools: start.tools,
|
|
143
151
|
emit,
|
|
144
152
|
requestToolResult: turn.requestToolResult,
|
|
@@ -151,7 +159,6 @@ async function ensureRuntime({
|
|
|
151
159
|
timeout: 30_000,
|
|
152
160
|
config: buildOpenCodeConfig({
|
|
153
161
|
start,
|
|
154
|
-
relayToken,
|
|
155
162
|
relayPort: runtime.relay?.port,
|
|
156
163
|
}) as never,
|
|
157
164
|
});
|
|
@@ -164,11 +171,9 @@ async function ensureRuntime({
|
|
|
164
171
|
|
|
165
172
|
function buildOpenCodeConfig({
|
|
166
173
|
start,
|
|
167
|
-
relayToken,
|
|
168
174
|
relayPort,
|
|
169
175
|
}: {
|
|
170
176
|
start: StartMessage;
|
|
171
|
-
relayToken: string | undefined;
|
|
172
177
|
relayPort: number | undefined;
|
|
173
178
|
}): Record<string, unknown> {
|
|
174
179
|
const config: Record<string, unknown> = {
|
|
@@ -191,7 +196,7 @@ function buildOpenCodeConfig({
|
|
|
191
196
|
if (skillsDir) config.skills = { paths: [skillsDir] };
|
|
192
197
|
const provider = buildProviderConfig(start);
|
|
193
198
|
if (provider) config.provider = provider;
|
|
194
|
-
if (
|
|
199
|
+
if (relayPort && start.tools && start.tools.length > 0) {
|
|
195
200
|
config.mcp = {
|
|
196
201
|
'harness-tools': {
|
|
197
202
|
type: 'local',
|
|
@@ -206,7 +211,6 @@ function buildOpenCodeConfig({
|
|
|
206
211
|
})),
|
|
207
212
|
),
|
|
208
213
|
TOOL_RELAY_URL: `http://127.0.0.1:${relayPort}`,
|
|
209
|
-
TOOL_RELAY_TOKEN: relayToken,
|
|
210
214
|
},
|
|
211
215
|
},
|
|
212
216
|
};
|
|
@@ -725,6 +729,7 @@ type TranslationState = {
|
|
|
725
729
|
toolNames: Map<string, { rawToolName: string; toolName: string }>;
|
|
726
730
|
toolCallsEmitted: Set<string>;
|
|
727
731
|
toolResultsEmitted: Set<string>;
|
|
732
|
+
hostToolCallsAuthorized: Set<string>;
|
|
728
733
|
shellCommands: Map<string, string>;
|
|
729
734
|
messageRoles: Map<string, string>;
|
|
730
735
|
turnUsage: Record<string, unknown> | undefined;
|
|
@@ -740,6 +745,7 @@ function createTranslationState(): TranslationState {
|
|
|
740
745
|
toolNames: new Map(),
|
|
741
746
|
toolCallsEmitted: new Set(),
|
|
742
747
|
toolResultsEmitted: new Set(),
|
|
748
|
+
hostToolCallsAuthorized: new Set(),
|
|
743
749
|
shellCommands: new Map(),
|
|
744
750
|
messageRoles: new Map(),
|
|
745
751
|
turnUsage: undefined,
|
|
@@ -922,7 +928,16 @@ async function translateAndEmit({
|
|
|
922
928
|
const rawToolName = String(props.tool ?? 'unknown');
|
|
923
929
|
const toolName = toWireToolName(rawToolName);
|
|
924
930
|
state.toolNames.set(callID, { rawToolName, toolName });
|
|
925
|
-
|
|
931
|
+
const hostToolName = getHostToolName(toolName, props.tool);
|
|
932
|
+
if (hostToolName) {
|
|
933
|
+
authorizeHostToolCall({
|
|
934
|
+
callID,
|
|
935
|
+
toolName: hostToolName,
|
|
936
|
+
input: props.input ?? parseToolInput(state, props),
|
|
937
|
+
state,
|
|
938
|
+
});
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
926
941
|
emit({
|
|
927
942
|
type: 'tool-call',
|
|
928
943
|
toolCallId: callID,
|
|
@@ -947,7 +962,7 @@ async function translateAndEmit({
|
|
|
947
962
|
String((props as { tool?: unknown }).tool ?? '');
|
|
948
963
|
const toolName =
|
|
949
964
|
cachedTool?.toolName ?? toWireToolName(rawToolName || 'unknown');
|
|
950
|
-
if (
|
|
965
|
+
if (getHostToolName(toolName, rawToolName)) return;
|
|
951
966
|
emit({
|
|
952
967
|
type: 'tool-result',
|
|
953
968
|
toolCallId: callID,
|
|
@@ -1152,7 +1167,18 @@ function emitLegacyToolPart({
|
|
|
1152
1167
|
const rawToolName = toolPart.tool;
|
|
1153
1168
|
const toolName = toWireToolName(rawToolName);
|
|
1154
1169
|
state.toolNames.set(callID, { rawToolName, toolName });
|
|
1155
|
-
|
|
1170
|
+
const hostToolName = getHostToolName(toolName, rawToolName);
|
|
1171
|
+
if (hostToolName) {
|
|
1172
|
+
if (status === 'running') {
|
|
1173
|
+
authorizeHostToolCall({
|
|
1174
|
+
callID,
|
|
1175
|
+
toolName: hostToolName,
|
|
1176
|
+
input: legacyToolPartInput(toolPart),
|
|
1177
|
+
state,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1156
1182
|
if (!state.toolCallsEmitted.has(callID)) {
|
|
1157
1183
|
state.toolCallsEmitted.add(callID);
|
|
1158
1184
|
emit({
|
|
@@ -1413,19 +1439,38 @@ function nativeNameField({
|
|
|
1413
1439
|
return { nativeName };
|
|
1414
1440
|
}
|
|
1415
1441
|
|
|
1416
|
-
function
|
|
1417
|
-
|
|
1442
|
+
function getHostToolName(
|
|
1443
|
+
toolName: string,
|
|
1444
|
+
rawToolName: unknown,
|
|
1445
|
+
): string | undefined {
|
|
1446
|
+
if (runtime.toolNames.has(toolName)) return toolName;
|
|
1418
1447
|
if (typeof rawToolName === 'string' && runtime.toolNames.has(rawToolName)) {
|
|
1419
|
-
return
|
|
1448
|
+
return rawToolName;
|
|
1420
1449
|
}
|
|
1421
1450
|
if (
|
|
1422
1451
|
typeof rawToolName === 'string' &&
|
|
1423
1452
|
rawToolName.startsWith('harness-tools_') &&
|
|
1424
1453
|
runtime.toolNames.has(rawToolName.slice('harness-tools_'.length))
|
|
1425
1454
|
) {
|
|
1426
|
-
return
|
|
1455
|
+
return rawToolName.slice('harness-tools_'.length);
|
|
1427
1456
|
}
|
|
1428
|
-
return
|
|
1457
|
+
return undefined;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function authorizeHostToolCall({
|
|
1461
|
+
callID,
|
|
1462
|
+
toolName,
|
|
1463
|
+
input,
|
|
1464
|
+
state,
|
|
1465
|
+
}: {
|
|
1466
|
+
callID: string;
|
|
1467
|
+
toolName: string;
|
|
1468
|
+
input: unknown;
|
|
1469
|
+
state: TranslationState;
|
|
1470
|
+
}): void {
|
|
1471
|
+
if (state.hostToolCallsAuthorized.has(callID)) return;
|
|
1472
|
+
state.hostToolCallsAuthorized.add(callID);
|
|
1473
|
+
runtime.relay?.authorizeToolCall({ toolName, input });
|
|
1429
1474
|
}
|
|
1430
1475
|
|
|
1431
1476
|
async function emitContextFallback({
|
|
@@ -1601,26 +1646,24 @@ function mapFinishReason(
|
|
|
1601
1646
|
}
|
|
1602
1647
|
|
|
1603
1648
|
async function startToolRelay({
|
|
1604
|
-
|
|
1649
|
+
allowedScriptPaths,
|
|
1605
1650
|
tools,
|
|
1606
1651
|
emit,
|
|
1607
1652
|
requestToolResult,
|
|
1608
1653
|
}: {
|
|
1609
|
-
|
|
1654
|
+
allowedScriptPaths: ReadonlyArray<string>;
|
|
1610
1655
|
tools: ReadonlyArray<{ name: string }>;
|
|
1611
1656
|
emit: Emit;
|
|
1612
1657
|
requestToolResult: (
|
|
1613
1658
|
toolCallId: string,
|
|
1614
1659
|
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
1615
|
-
}): Promise<
|
|
1660
|
+
}): Promise<ToolRelay> {
|
|
1616
1661
|
const toolNames = new Set(tools.map(t => t.name));
|
|
1662
|
+
const allowedScriptPathSet = new Set(allowedScriptPaths);
|
|
1663
|
+
const authorizer = new ToolRelayAuthorizer();
|
|
1617
1664
|
const server = createServer(async (req, res) => {
|
|
1618
1665
|
try {
|
|
1619
|
-
if (
|
|
1620
|
-
req.method !== 'POST' ||
|
|
1621
|
-
req.url !== '/' ||
|
|
1622
|
-
req.headers.authorization !== `Bearer ${relayToken}`
|
|
1623
|
-
) {
|
|
1666
|
+
if (req.method !== 'POST' || req.url !== '/') {
|
|
1624
1667
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1625
1668
|
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1626
1669
|
return;
|
|
@@ -1643,6 +1686,17 @@ async function startToolRelay({
|
|
|
1643
1686
|
);
|
|
1644
1687
|
return;
|
|
1645
1688
|
}
|
|
1689
|
+
const authorized =
|
|
1690
|
+
authorizer.consumeToolCall({ toolName, input }) ||
|
|
1691
|
+
(await isToolRelayRequestFromAllowedProcess({
|
|
1692
|
+
socket: req.socket,
|
|
1693
|
+
allowedScriptPaths: allowedScriptPathSet,
|
|
1694
|
+
}));
|
|
1695
|
+
if (!authorized) {
|
|
1696
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1697
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1646
1700
|
|
|
1647
1701
|
emit({
|
|
1648
1702
|
type: 'tool-call',
|
|
@@ -1683,6 +1737,7 @@ async function startToolRelay({
|
|
|
1683
1737
|
return {
|
|
1684
1738
|
port: address.port,
|
|
1685
1739
|
close: () => closeServer(server),
|
|
1740
|
+
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
1686
1741
|
};
|
|
1687
1742
|
}
|
|
1688
1743
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
|
|
4
|
+
export type ToolRelayCall = {
|
|
5
|
+
toolName: string;
|
|
6
|
+
input: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class ToolRelayAuthorizer {
|
|
10
|
+
private readonly ttlMs: number;
|
|
11
|
+
private readonly now: () => number;
|
|
12
|
+
private readonly authorizations: Array<{ key: string; expiresAt: number }> =
|
|
13
|
+
[];
|
|
14
|
+
|
|
15
|
+
constructor({
|
|
16
|
+
ttlMs = 10_000,
|
|
17
|
+
now = Date.now,
|
|
18
|
+
}: {
|
|
19
|
+
ttlMs?: number;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
} = {}) {
|
|
22
|
+
this.ttlMs = ttlMs;
|
|
23
|
+
this.now = now;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
authorizeToolCall(call: ToolRelayCall): void {
|
|
27
|
+
this.pruneExpired();
|
|
28
|
+
this.authorizations.push({
|
|
29
|
+
key: toolRelayCallKey(call),
|
|
30
|
+
expiresAt: this.now() + this.ttlMs,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
consumeToolCall(call: ToolRelayCall): boolean {
|
|
35
|
+
this.pruneExpired();
|
|
36
|
+
const key = toolRelayCallKey(call);
|
|
37
|
+
const index = this.authorizations.findIndex(auth => auth.key === key);
|
|
38
|
+
if (index === -1) return false;
|
|
39
|
+
this.authorizations.splice(index, 1);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private pruneExpired(): void {
|
|
44
|
+
const now = this.now();
|
|
45
|
+
for (let i = this.authorizations.length - 1; i >= 0; i--) {
|
|
46
|
+
if (this.authorizations[i].expiresAt <= now) {
|
|
47
|
+
this.authorizations.splice(i, 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toolRelayCallKey({ toolName, input }: ToolRelayCall): string {
|
|
54
|
+
return `${toolName}\0${canonicalJson(input ?? {})}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function canonicalJson(value: unknown): string {
|
|
58
|
+
return JSON.stringify(normalizeJsonValue(value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeJsonValue(value: unknown): unknown {
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
return value.map(normalizeJsonValue);
|
|
64
|
+
}
|
|
65
|
+
if (value && typeof value === 'object') {
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.entries(value as Record<string, unknown>)
|
|
68
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
69
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
70
|
+
.map(([key, entryValue]) => [key, normalizeJsonValue(entryValue)]),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
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
|
+
}
|