@ai-sdk/harness-opencode 1.0.10 → 1.0.12
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 +17 -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 +227 -19
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.js +20 -78
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/bridge/host-tool-mcp.ts +0 -2
- package/src/bridge/index.ts +170 -27
- package/src/bridge/tool-relay-auth.ts +151 -0
- package/src/opencode-harness.ts +19 -85
package/src/bridge/index.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
type BridgeEvent,
|
|
4
4
|
type BridgeTurn,
|
|
5
5
|
} from '@ai-sdk/harness/bridge';
|
|
6
|
-
import type { HarnessV1BuiltinToolName } from '@ai-sdk/harness';
|
|
7
6
|
import { randomUUID } from 'node:crypto';
|
|
8
7
|
import { mkdirSync } from 'node:fs';
|
|
9
8
|
import { createServer, type Server } from 'node:http';
|
|
@@ -32,21 +31,39 @@ import {
|
|
|
32
31
|
type HarnessUsage,
|
|
33
32
|
type OpenCodeTokenUsage,
|
|
34
33
|
} from './opencode-usage';
|
|
34
|
+
import {
|
|
35
|
+
ToolRelayAuthorizer,
|
|
36
|
+
isToolRelayRequestFromAllowedProcess,
|
|
37
|
+
type ToolRelayCall,
|
|
38
|
+
} from './tool-relay-auth';
|
|
35
39
|
|
|
36
40
|
type Emit = (msg: Record<string, unknown>) => void;
|
|
37
41
|
|
|
38
42
|
type OpenCodeClient = ReturnType<typeof createOpencodeClient>;
|
|
39
43
|
type OpenCodeServer = Awaited<ReturnType<typeof createOpencodeServer>>;
|
|
44
|
+
type ToolRelay = {
|
|
45
|
+
port: number;
|
|
46
|
+
close(): void;
|
|
47
|
+
authorizeToolCall(call: ToolRelayCall): void;
|
|
48
|
+
};
|
|
40
49
|
|
|
41
50
|
type RuntimeState = {
|
|
42
51
|
server?: OpenCodeServer;
|
|
43
52
|
client?: OpenCodeClient;
|
|
44
53
|
sessionId?: string;
|
|
45
|
-
relay?:
|
|
54
|
+
relay?: ToolRelay;
|
|
46
55
|
toolNames: Set<string>;
|
|
47
56
|
};
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
type CommonBuiltinToolName =
|
|
59
|
+
| 'read'
|
|
60
|
+
| 'write'
|
|
61
|
+
| 'edit'
|
|
62
|
+
| 'bash'
|
|
63
|
+
| 'glob'
|
|
64
|
+
| 'grep';
|
|
65
|
+
|
|
66
|
+
const NATIVE_TO_COMMON: Readonly<Record<string, CommonBuiltinToolName>> = {
|
|
50
67
|
view: 'read',
|
|
51
68
|
read: 'read',
|
|
52
69
|
write: 'write',
|
|
@@ -65,6 +82,20 @@ const OPENCODE_TO_WIRE: Readonly<Record<string, string>> = {
|
|
|
65
82
|
subtask: 'agent',
|
|
66
83
|
};
|
|
67
84
|
|
|
85
|
+
const PUBLIC_TO_NATIVE: Readonly<Record<string, string>> = {
|
|
86
|
+
read: 'view',
|
|
87
|
+
write: 'write',
|
|
88
|
+
edit: 'edit',
|
|
89
|
+
bash: 'bash',
|
|
90
|
+
glob: 'glob',
|
|
91
|
+
grep: 'grep',
|
|
92
|
+
ls: 'list',
|
|
93
|
+
webfetch: 'webfetch',
|
|
94
|
+
skill: 'skill',
|
|
95
|
+
todowrite: 'todowrite',
|
|
96
|
+
agent: 'agent',
|
|
97
|
+
};
|
|
98
|
+
|
|
68
99
|
const TOOL_KIND: Readonly<Record<string, 'readonly' | 'edit' | 'bash'>> = {
|
|
69
100
|
read: 'readonly',
|
|
70
101
|
glob: 'readonly',
|
|
@@ -133,12 +164,10 @@ async function ensureRuntime({
|
|
|
133
164
|
}): Promise<void> {
|
|
134
165
|
if (runtime.client) return;
|
|
135
166
|
|
|
136
|
-
let relayToken: string | undefined;
|
|
137
167
|
if (start.tools && start.tools.length > 0) {
|
|
138
|
-
relayToken = randomUUID();
|
|
139
168
|
runtime.toolNames = new Set(start.tools.map(tool => tool.name));
|
|
140
169
|
runtime.relay = await startToolRelay({
|
|
141
|
-
|
|
170
|
+
allowedScriptPaths: [`${bootstrapDir}/host-tool-mcp.mjs`],
|
|
142
171
|
tools: start.tools,
|
|
143
172
|
emit,
|
|
144
173
|
requestToolResult: turn.requestToolResult,
|
|
@@ -151,7 +180,6 @@ async function ensureRuntime({
|
|
|
151
180
|
timeout: 30_000,
|
|
152
181
|
config: buildOpenCodeConfig({
|
|
153
182
|
start,
|
|
154
|
-
relayToken,
|
|
155
183
|
relayPort: runtime.relay?.port,
|
|
156
184
|
}) as never,
|
|
157
185
|
});
|
|
@@ -164,11 +192,9 @@ async function ensureRuntime({
|
|
|
164
192
|
|
|
165
193
|
function buildOpenCodeConfig({
|
|
166
194
|
start,
|
|
167
|
-
relayToken,
|
|
168
195
|
relayPort,
|
|
169
196
|
}: {
|
|
170
197
|
start: StartMessage;
|
|
171
|
-
relayToken: string | undefined;
|
|
172
198
|
relayPort: number | undefined;
|
|
173
199
|
}): Record<string, unknown> {
|
|
174
200
|
const config: Record<string, unknown> = {
|
|
@@ -189,9 +215,21 @@ function buildOpenCodeConfig({
|
|
|
189
215
|
};
|
|
190
216
|
if (start.model) config.model = start.model;
|
|
191
217
|
if (skillsDir) config.skills = { paths: [skillsDir] };
|
|
218
|
+
const inactiveToolNames = resolveInactiveBuiltinToolNames(start);
|
|
219
|
+
const permission = config.permission as Record<string, unknown>;
|
|
220
|
+
for (const toolName of inactiveToolNames) {
|
|
221
|
+
const permissionName = toPermissionToolName(
|
|
222
|
+
PUBLIC_TO_NATIVE[toolName] ?? toolName,
|
|
223
|
+
);
|
|
224
|
+
if (permissionName === 'ls') {
|
|
225
|
+
permission.list = 'ask';
|
|
226
|
+
} else {
|
|
227
|
+
permission[permissionName] = 'ask';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
192
230
|
const provider = buildProviderConfig(start);
|
|
193
231
|
if (provider) config.provider = provider;
|
|
194
|
-
if (
|
|
232
|
+
if (relayPort && start.tools && start.tools.length > 0) {
|
|
195
233
|
config.mcp = {
|
|
196
234
|
'harness-tools': {
|
|
197
235
|
type: 'local',
|
|
@@ -206,7 +244,6 @@ function buildOpenCodeConfig({
|
|
|
206
244
|
})),
|
|
207
245
|
),
|
|
208
246
|
TOOL_RELAY_URL: `http://127.0.0.1:${relayPort}`,
|
|
209
|
-
TOOL_RELAY_TOKEN: relayToken,
|
|
210
247
|
},
|
|
211
248
|
},
|
|
212
249
|
};
|
|
@@ -498,6 +535,7 @@ async function runPrompt({
|
|
|
498
535
|
client,
|
|
499
536
|
sessionId,
|
|
500
537
|
permissionMode: start.permissionMode,
|
|
538
|
+
builtinToolFiltering: start.builtinToolFiltering,
|
|
501
539
|
turn,
|
|
502
540
|
emit: msg => {
|
|
503
541
|
if (msg.type === 'text-delta' || msg.type === 'reasoning-delta') {
|
|
@@ -620,6 +658,7 @@ async function runCompaction({
|
|
|
620
658
|
client,
|
|
621
659
|
sessionId,
|
|
622
660
|
permissionMode: start.permissionMode,
|
|
661
|
+
builtinToolFiltering: start.builtinToolFiltering,
|
|
623
662
|
turn,
|
|
624
663
|
emit: msg => {
|
|
625
664
|
if (msg.type === 'compaction') sawCompaction = true;
|
|
@@ -684,6 +723,7 @@ async function consumeEvents({
|
|
|
684
723
|
client,
|
|
685
724
|
sessionId,
|
|
686
725
|
permissionMode,
|
|
726
|
+
builtinToolFiltering,
|
|
687
727
|
turn,
|
|
688
728
|
emit,
|
|
689
729
|
signal,
|
|
@@ -692,6 +732,7 @@ async function consumeEvents({
|
|
|
692
732
|
client: OpenCodeClient;
|
|
693
733
|
sessionId: string;
|
|
694
734
|
permissionMode: StartMessage['permissionMode'];
|
|
735
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
695
736
|
turn: BridgeTurn;
|
|
696
737
|
emit: Emit;
|
|
697
738
|
signal: AbortSignal;
|
|
@@ -710,6 +751,7 @@ async function consumeEvents({
|
|
|
710
751
|
state,
|
|
711
752
|
sessionId,
|
|
712
753
|
permissionMode,
|
|
754
|
+
builtinToolFiltering,
|
|
713
755
|
client,
|
|
714
756
|
turn,
|
|
715
757
|
emit,
|
|
@@ -725,6 +767,7 @@ type TranslationState = {
|
|
|
725
767
|
toolNames: Map<string, { rawToolName: string; toolName: string }>;
|
|
726
768
|
toolCallsEmitted: Set<string>;
|
|
727
769
|
toolResultsEmitted: Set<string>;
|
|
770
|
+
hostToolCallsAuthorized: Set<string>;
|
|
728
771
|
shellCommands: Map<string, string>;
|
|
729
772
|
messageRoles: Map<string, string>;
|
|
730
773
|
turnUsage: Record<string, unknown> | undefined;
|
|
@@ -740,6 +783,7 @@ function createTranslationState(): TranslationState {
|
|
|
740
783
|
toolNames: new Map(),
|
|
741
784
|
toolCallsEmitted: new Set(),
|
|
742
785
|
toolResultsEmitted: new Set(),
|
|
786
|
+
hostToolCallsAuthorized: new Set(),
|
|
743
787
|
shellCommands: new Map(),
|
|
744
788
|
messageRoles: new Map(),
|
|
745
789
|
turnUsage: undefined,
|
|
@@ -753,6 +797,7 @@ async function translateAndEmit({
|
|
|
753
797
|
state,
|
|
754
798
|
sessionId,
|
|
755
799
|
permissionMode,
|
|
800
|
+
builtinToolFiltering,
|
|
756
801
|
client,
|
|
757
802
|
turn,
|
|
758
803
|
emit,
|
|
@@ -761,6 +806,7 @@ async function translateAndEmit({
|
|
|
761
806
|
state: TranslationState;
|
|
762
807
|
sessionId: string;
|
|
763
808
|
permissionMode: StartMessage['permissionMode'];
|
|
809
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
764
810
|
client: OpenCodeClient;
|
|
765
811
|
turn: BridgeTurn;
|
|
766
812
|
emit: Emit;
|
|
@@ -922,7 +968,16 @@ async function translateAndEmit({
|
|
|
922
968
|
const rawToolName = String(props.tool ?? 'unknown');
|
|
923
969
|
const toolName = toWireToolName(rawToolName);
|
|
924
970
|
state.toolNames.set(callID, { rawToolName, toolName });
|
|
925
|
-
|
|
971
|
+
const hostToolName = getHostToolName(toolName, props.tool);
|
|
972
|
+
if (hostToolName) {
|
|
973
|
+
authorizeHostToolCall({
|
|
974
|
+
callID,
|
|
975
|
+
toolName: hostToolName,
|
|
976
|
+
input: props.input ?? parseToolInput(state, props),
|
|
977
|
+
state,
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
926
981
|
emit({
|
|
927
982
|
type: 'tool-call',
|
|
928
983
|
toolCallId: callID,
|
|
@@ -947,7 +1002,7 @@ async function translateAndEmit({
|
|
|
947
1002
|
String((props as { tool?: unknown }).tool ?? '');
|
|
948
1003
|
const toolName =
|
|
949
1004
|
cachedTool?.toolName ?? toWireToolName(rawToolName || 'unknown');
|
|
950
|
-
if (
|
|
1005
|
+
if (getHostToolName(toolName, rawToolName)) return;
|
|
951
1006
|
emit({
|
|
952
1007
|
type: 'tool-result',
|
|
953
1008
|
toolCallId: callID,
|
|
@@ -1007,6 +1062,7 @@ async function translateAndEmit({
|
|
|
1007
1062
|
client,
|
|
1008
1063
|
sessionId,
|
|
1009
1064
|
permissionMode,
|
|
1065
|
+
builtinToolFiltering,
|
|
1010
1066
|
turn,
|
|
1011
1067
|
emit,
|
|
1012
1068
|
event,
|
|
@@ -1018,6 +1074,7 @@ async function translateAndEmit({
|
|
|
1018
1074
|
client,
|
|
1019
1075
|
sessionId,
|
|
1020
1076
|
permissionMode,
|
|
1077
|
+
builtinToolFiltering,
|
|
1021
1078
|
turn,
|
|
1022
1079
|
emit,
|
|
1023
1080
|
event,
|
|
@@ -1152,7 +1209,18 @@ function emitLegacyToolPart({
|
|
|
1152
1209
|
const rawToolName = toolPart.tool;
|
|
1153
1210
|
const toolName = toWireToolName(rawToolName);
|
|
1154
1211
|
state.toolNames.set(callID, { rawToolName, toolName });
|
|
1155
|
-
|
|
1212
|
+
const hostToolName = getHostToolName(toolName, rawToolName);
|
|
1213
|
+
if (hostToolName) {
|
|
1214
|
+
if (status === 'running') {
|
|
1215
|
+
authorizeHostToolCall({
|
|
1216
|
+
callID,
|
|
1217
|
+
toolName: hostToolName,
|
|
1218
|
+
input: legacyToolPartInput(toolPart),
|
|
1219
|
+
state,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1156
1224
|
if (!state.toolCallsEmitted.has(callID)) {
|
|
1157
1225
|
state.toolCallsEmitted.add(callID);
|
|
1158
1226
|
emit({
|
|
@@ -1242,6 +1310,7 @@ async function handlePermissionV2({
|
|
|
1242
1310
|
client,
|
|
1243
1311
|
sessionId,
|
|
1244
1312
|
permissionMode,
|
|
1313
|
+
builtinToolFiltering,
|
|
1245
1314
|
turn,
|
|
1246
1315
|
emit,
|
|
1247
1316
|
event,
|
|
@@ -1249,6 +1318,7 @@ async function handlePermissionV2({
|
|
|
1249
1318
|
client: OpenCodeClient;
|
|
1250
1319
|
sessionId: string;
|
|
1251
1320
|
permissionMode: StartMessage['permissionMode'];
|
|
1321
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
1252
1322
|
turn: BridgeTurn;
|
|
1253
1323
|
emit: Emit;
|
|
1254
1324
|
event: OpenCodeEvent;
|
|
@@ -1269,6 +1339,7 @@ async function handlePermissionV2({
|
|
|
1269
1339
|
? String((props.source as { callID?: unknown }).callID)
|
|
1270
1340
|
: requestID,
|
|
1271
1341
|
permissionMode,
|
|
1342
|
+
builtinToolFiltering,
|
|
1272
1343
|
turn,
|
|
1273
1344
|
emit,
|
|
1274
1345
|
});
|
|
@@ -1284,6 +1355,7 @@ async function handlePermission({
|
|
|
1284
1355
|
client,
|
|
1285
1356
|
sessionId,
|
|
1286
1357
|
permissionMode,
|
|
1358
|
+
builtinToolFiltering,
|
|
1287
1359
|
turn,
|
|
1288
1360
|
emit,
|
|
1289
1361
|
event,
|
|
@@ -1291,6 +1363,7 @@ async function handlePermission({
|
|
|
1291
1363
|
client: OpenCodeClient;
|
|
1292
1364
|
sessionId: string;
|
|
1293
1365
|
permissionMode: StartMessage['permissionMode'];
|
|
1366
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
1294
1367
|
turn: BridgeTurn;
|
|
1295
1368
|
emit: Emit;
|
|
1296
1369
|
event: OpenCodeEvent;
|
|
@@ -1309,6 +1382,7 @@ async function handlePermission({
|
|
|
1309
1382
|
? String((props.tool as { callID?: unknown }).callID)
|
|
1310
1383
|
: requestID,
|
|
1311
1384
|
permissionMode,
|
|
1385
|
+
builtinToolFiltering,
|
|
1312
1386
|
turn,
|
|
1313
1387
|
emit,
|
|
1314
1388
|
});
|
|
@@ -1327,6 +1401,7 @@ async function selectPermissionReply({
|
|
|
1327
1401
|
requestID,
|
|
1328
1402
|
toolCallId,
|
|
1329
1403
|
permissionMode,
|
|
1404
|
+
builtinToolFiltering,
|
|
1330
1405
|
turn,
|
|
1331
1406
|
emit,
|
|
1332
1407
|
}: {
|
|
@@ -1335,6 +1410,7 @@ async function selectPermissionReply({
|
|
|
1335
1410
|
requestID: string;
|
|
1336
1411
|
toolCallId: string;
|
|
1337
1412
|
permissionMode: StartMessage['permissionMode'];
|
|
1413
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
1338
1414
|
turn: BridgeTurn;
|
|
1339
1415
|
emit: Emit;
|
|
1340
1416
|
}): Promise<{ reply: 'once' | 'always' | 'reject'; message?: string }> {
|
|
@@ -1342,6 +1418,22 @@ async function selectPermissionReply({
|
|
|
1342
1418
|
if (resources.some(resource => isExternalPath(resource))) {
|
|
1343
1419
|
return { reply: 'reject', message: 'External directory access rejected.' };
|
|
1344
1420
|
}
|
|
1421
|
+
if (
|
|
1422
|
+
isBuiltinToolInactive({ toolName, toolFiltering: builtinToolFiltering })
|
|
1423
|
+
) {
|
|
1424
|
+
emit({
|
|
1425
|
+
type: 'tool-approval-request',
|
|
1426
|
+
approvalId: requestID,
|
|
1427
|
+
toolCallId,
|
|
1428
|
+
});
|
|
1429
|
+
const decision = await turn.requestToolApproval(requestID);
|
|
1430
|
+
return decision.approved
|
|
1431
|
+
? { reply: 'once' }
|
|
1432
|
+
: {
|
|
1433
|
+
reply: 'reject',
|
|
1434
|
+
...(decision.reason ? { message: decision.reason } : {}),
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1345
1437
|
if (!permissionMode || permissionMode === 'allow-all') {
|
|
1346
1438
|
return { reply: 'always' };
|
|
1347
1439
|
}
|
|
@@ -1382,6 +1474,28 @@ function toPermissionToolName(action: string): string {
|
|
|
1382
1474
|
return toWireToolName(normalized);
|
|
1383
1475
|
}
|
|
1384
1476
|
|
|
1477
|
+
function resolveInactiveBuiltinToolNames(
|
|
1478
|
+
start: StartMessage,
|
|
1479
|
+
): ReadonlyArray<string> {
|
|
1480
|
+
const toolFiltering = start.builtinToolFiltering;
|
|
1481
|
+
if (toolFiltering == null) return [];
|
|
1482
|
+
return toolFiltering.mode === 'allow'
|
|
1483
|
+
? Object.keys(PUBLIC_TO_NATIVE).filter(
|
|
1484
|
+
name => !toolFiltering.toolNames.includes(name),
|
|
1485
|
+
)
|
|
1486
|
+
: toolFiltering.toolNames;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function isBuiltinToolInactive(input: {
|
|
1490
|
+
toolName: string;
|
|
1491
|
+
toolFiltering: StartMessage['builtinToolFiltering'];
|
|
1492
|
+
}): boolean {
|
|
1493
|
+
if (input.toolFiltering == null) return false;
|
|
1494
|
+
return input.toolFiltering.mode === 'allow'
|
|
1495
|
+
? !input.toolFiltering.toolNames.includes(input.toolName)
|
|
1496
|
+
: input.toolFiltering.toolNames.includes(input.toolName);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1385
1499
|
function isExternalPath(resource: string): boolean {
|
|
1386
1500
|
if (!path.isAbsolute(resource)) return false;
|
|
1387
1501
|
const normalized = path.resolve(resource);
|
|
@@ -1413,19 +1527,38 @@ function nativeNameField({
|
|
|
1413
1527
|
return { nativeName };
|
|
1414
1528
|
}
|
|
1415
1529
|
|
|
1416
|
-
function
|
|
1417
|
-
|
|
1530
|
+
function getHostToolName(
|
|
1531
|
+
toolName: string,
|
|
1532
|
+
rawToolName: unknown,
|
|
1533
|
+
): string | undefined {
|
|
1534
|
+
if (runtime.toolNames.has(toolName)) return toolName;
|
|
1418
1535
|
if (typeof rawToolName === 'string' && runtime.toolNames.has(rawToolName)) {
|
|
1419
|
-
return
|
|
1536
|
+
return rawToolName;
|
|
1420
1537
|
}
|
|
1421
1538
|
if (
|
|
1422
1539
|
typeof rawToolName === 'string' &&
|
|
1423
1540
|
rawToolName.startsWith('harness-tools_') &&
|
|
1424
1541
|
runtime.toolNames.has(rawToolName.slice('harness-tools_'.length))
|
|
1425
1542
|
) {
|
|
1426
|
-
return
|
|
1543
|
+
return rawToolName.slice('harness-tools_'.length);
|
|
1427
1544
|
}
|
|
1428
|
-
return
|
|
1545
|
+
return undefined;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function authorizeHostToolCall({
|
|
1549
|
+
callID,
|
|
1550
|
+
toolName,
|
|
1551
|
+
input,
|
|
1552
|
+
state,
|
|
1553
|
+
}: {
|
|
1554
|
+
callID: string;
|
|
1555
|
+
toolName: string;
|
|
1556
|
+
input: unknown;
|
|
1557
|
+
state: TranslationState;
|
|
1558
|
+
}): void {
|
|
1559
|
+
if (state.hostToolCallsAuthorized.has(callID)) return;
|
|
1560
|
+
state.hostToolCallsAuthorized.add(callID);
|
|
1561
|
+
runtime.relay?.authorizeToolCall({ toolName, input });
|
|
1429
1562
|
}
|
|
1430
1563
|
|
|
1431
1564
|
async function emitContextFallback({
|
|
@@ -1601,26 +1734,24 @@ function mapFinishReason(
|
|
|
1601
1734
|
}
|
|
1602
1735
|
|
|
1603
1736
|
async function startToolRelay({
|
|
1604
|
-
|
|
1737
|
+
allowedScriptPaths,
|
|
1605
1738
|
tools,
|
|
1606
1739
|
emit,
|
|
1607
1740
|
requestToolResult,
|
|
1608
1741
|
}: {
|
|
1609
|
-
|
|
1742
|
+
allowedScriptPaths: ReadonlyArray<string>;
|
|
1610
1743
|
tools: ReadonlyArray<{ name: string }>;
|
|
1611
1744
|
emit: Emit;
|
|
1612
1745
|
requestToolResult: (
|
|
1613
1746
|
toolCallId: string,
|
|
1614
1747
|
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
1615
|
-
}): Promise<
|
|
1748
|
+
}): Promise<ToolRelay> {
|
|
1616
1749
|
const toolNames = new Set(tools.map(t => t.name));
|
|
1750
|
+
const allowedScriptPathSet = new Set(allowedScriptPaths);
|
|
1751
|
+
const authorizer = new ToolRelayAuthorizer();
|
|
1617
1752
|
const server = createServer(async (req, res) => {
|
|
1618
1753
|
try {
|
|
1619
|
-
if (
|
|
1620
|
-
req.method !== 'POST' ||
|
|
1621
|
-
req.url !== '/' ||
|
|
1622
|
-
req.headers.authorization !== `Bearer ${relayToken}`
|
|
1623
|
-
) {
|
|
1754
|
+
if (req.method !== 'POST' || req.url !== '/') {
|
|
1624
1755
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1625
1756
|
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1626
1757
|
return;
|
|
@@ -1643,6 +1774,17 @@ async function startToolRelay({
|
|
|
1643
1774
|
);
|
|
1644
1775
|
return;
|
|
1645
1776
|
}
|
|
1777
|
+
const authorized =
|
|
1778
|
+
authorizer.consumeToolCall({ toolName, input }) ||
|
|
1779
|
+
(await isToolRelayRequestFromAllowedProcess({
|
|
1780
|
+
socket: req.socket,
|
|
1781
|
+
allowedScriptPaths: allowedScriptPathSet,
|
|
1782
|
+
}));
|
|
1783
|
+
if (!authorized) {
|
|
1784
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1785
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1646
1788
|
|
|
1647
1789
|
emit({
|
|
1648
1790
|
type: 'tool-call',
|
|
@@ -1683,6 +1825,7 @@ async function startToolRelay({
|
|
|
1683
1825
|
return {
|
|
1684
1826
|
port: address.port,
|
|
1685
1827
|
close: () => closeServer(server),
|
|
1828
|
+
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
1686
1829
|
};
|
|
1687
1830
|
}
|
|
1688
1831
|
|
|
@@ -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
|
+
}
|