@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-opencode",
3
- "version": "1.0.9",
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.9",
31
- "@ai-sdk/provider-utils": "5.0.1"
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
  });
@@ -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?: { port: number; close(): void };
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
- relayToken,
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 (relayToken && relayPort && start.tools && start.tools.length > 0) {
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
- if (isHostTool(toolName, props.tool)) return;
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 (isHostTool(toolName, rawToolName)) return;
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
- if (isHostTool(toolName, rawToolName)) return;
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 isHostTool(toolName: string, rawToolName: unknown): boolean {
1417
- if (runtime.toolNames.has(toolName)) return true;
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 true;
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 true;
1455
+ return rawToolName.slice('harness-tools_'.length);
1427
1456
  }
1428
- return false;
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
- relayToken,
1649
+ allowedScriptPaths,
1605
1650
  tools,
1606
1651
  emit,
1607
1652
  requestToolResult,
1608
1653
  }: {
1609
- relayToken: string;
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<{ port: number; close(): void }> {
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
+ }