@ai-sdk/harness-acp 1.0.76 → 1.0.78

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/index.js CHANGED
@@ -2138,7 +2138,7 @@ function deserializeBridgeError({ error, harnessId }) {
2138
2138
  }
2139
2139
  //#endregion
2140
2140
  //#region src/version.ts
2141
- const VERSION = "1.0.76";
2141
+ const VERSION = "1.0.78";
2142
2142
  //#endregion
2143
2143
  //#region src/acp-harness.ts
2144
2144
  const ACP_CLIENT_APP = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-acp",
3
- "version": "1.0.76",
3
+ "version": "1.0.78",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,8 +26,8 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.138",
30
- "@ai-sdk/provider-utils": "5.0.53",
29
+ "@ai-sdk/harness": "1.0.139",
30
+ "@ai-sdk/provider-utils": "5.0.54",
31
31
  "ws": "^8.21.0"
32
32
  },
33
33
  "peerDependencies": {
@@ -0,0 +1,17 @@
1
+ export function canonicalFingerprint({ value }: { value: unknown }): string {
2
+ return JSON.stringify(canonicalizeJSON({ value }));
3
+ }
4
+
5
+ function canonicalizeJSON({ value }: { value: unknown }): unknown {
6
+ if (Array.isArray(value)) {
7
+ return value.map(item => canonicalizeJSON({ value: item }));
8
+ }
9
+ if (value == null || typeof value !== 'object') return value;
10
+ const record = value as Readonly<Record<string, unknown>>;
11
+ return Object.fromEntries(
12
+ Object.keys(record)
13
+ .sort()
14
+ .filter(key => record[key] !== undefined)
15
+ .map(key => [key, canonicalizeJSON({ value: record[key] })]),
16
+ );
17
+ }
@@ -3,6 +3,7 @@ import type {
3
3
  ToolCallUpdate,
4
4
  } from '@agentclientprotocol/sdk';
5
5
  import type { ACPToolCall } from '../../acp-tool-call';
6
+ import { canonicalFingerprint } from './canonical-json-fingerprint';
6
7
 
7
8
  type SessionUpdateMessage = Extract<
8
9
  ActiveSessionMessage,
@@ -449,23 +450,11 @@ function containsCombinedIdentity({
449
450
  serverName: string;
450
451
  toolName: string;
451
452
  }): boolean {
452
- const seen = new Set<object>();
453
- const visit = (candidate: unknown): boolean => {
454
- if (typeof candidate === 'string') {
455
- return hasDelimitedPair({ value: candidate, serverName, toolName });
456
- }
457
- if (candidate == null || typeof candidate !== 'object') return false;
458
- if (seen.has(candidate)) return false;
459
- seen.add(candidate);
460
- const values = Array.isArray(candidate)
461
- ? candidate
462
- : Object.values(candidate);
463
- for (const item of values) {
464
- if (visit(item)) return true;
465
- }
466
- return false;
467
- };
468
- return visit(value);
453
+ return containsString({
454
+ value,
455
+ matches: candidate =>
456
+ hasDelimitedPair({ value: candidate, serverName, toolName }),
457
+ });
469
458
  }
470
459
 
471
460
  function hasDelimitedPair({
@@ -570,10 +559,20 @@ function containsExactValue({
570
559
  }: {
571
560
  value: unknown;
572
561
  target: string;
562
+ }): boolean {
563
+ return containsString({ value, matches: candidate => candidate === target });
564
+ }
565
+
566
+ function containsString({
567
+ value,
568
+ matches,
569
+ }: {
570
+ value: unknown;
571
+ matches: (candidate: string) => boolean;
573
572
  }): boolean {
574
573
  const seen = new Set<object>();
575
574
  const visit = (candidate: unknown): boolean => {
576
- if (candidate === target) return true;
575
+ if (typeof candidate === 'string') return matches(candidate);
577
576
  if (candidate == null || typeof candidate !== 'object') return false;
578
577
  if (seen.has(candidate)) return false;
579
578
  seen.add(candidate);
@@ -613,28 +612,6 @@ function containsFingerprint({
613
612
  return visit(value);
614
613
  }
615
614
 
616
- function canonicalFingerprint({ value }: { value: unknown }): string {
617
- return JSON.stringify(canonicalizeJSON({ value }));
618
- }
619
-
620
- function canonicalizeJSON({ value }: { value: unknown }): unknown {
621
- if (Array.isArray(value)) {
622
- return value.map(item => canonicalizeJSON({ value: item }));
623
- }
624
- if (value == null || typeof value !== 'object') return value;
625
- return Object.fromEntries(
626
- Object.keys(value as Record<string, unknown>)
627
- .sort()
628
- .filter(key => (value as Record<string, unknown>)[key] !== undefined)
629
- .map(key => [
630
- key,
631
- canonicalizeJSON({
632
- value: (value as Record<string, unknown>)[key],
633
- }),
634
- ]),
635
- );
636
- }
637
-
638
615
  function getProperty({
639
616
  value,
640
617
  property,
@@ -0,0 +1,234 @@
1
+ import type { SessionUpdate, ToolCallUpdate } from '@agentclientprotocol/sdk';
2
+ import { canonicalFingerprint } from './canonical-json-fingerprint';
3
+
4
+ type HostToolCall = {
5
+ readonly toolName: string;
6
+ readonly input: Readonly<Record<string, unknown>>;
7
+ };
8
+
9
+ type ObservedCall = {
10
+ toolCall: ToolCallUpdate;
11
+ consumed: boolean;
12
+ terminal: boolean;
13
+ };
14
+
15
+ const DEFAULT_AUTHORIZATION_WAIT_MS = 10_000;
16
+
17
+ export function createHostToolRelayAuthorization({
18
+ serverName,
19
+ toolNames,
20
+ ttlMs = DEFAULT_AUTHORIZATION_WAIT_MS,
21
+ }: {
22
+ serverName: string;
23
+ toolNames: ReadonlyArray<string>;
24
+ ttlMs?: number;
25
+ }): {
26
+ observeUpdate(options: { update: SessionUpdate }): void;
27
+ observeAllowedPermission(options: { toolCall: ToolCallUpdate }): void;
28
+ waitForToolCallAuthorization(options: HostToolCall): Promise<boolean>;
29
+ close(): void;
30
+ } {
31
+ const observedCalls = new Map<string, ObservedCall>();
32
+ const authorizations = new Map<string, string>();
33
+ const pendingRequests: Array<{
34
+ key: string;
35
+ timeout: ReturnType<typeof setTimeout>;
36
+ resolve: (authorized: boolean) => void;
37
+ }> = [];
38
+ let closed = false;
39
+
40
+ const observe = ({ toolCall }: { toolCall: ToolCallUpdate }) => {
41
+ if (closed) return;
42
+ const previous = observedCalls.get(toolCall.toolCallId);
43
+ if (previous?.consumed || previous?.terminal) return;
44
+ const merged: ToolCallUpdate = {
45
+ ...previous?.toolCall,
46
+ ...toolCall,
47
+ ...(toolCall.name == null && previous?.toolCall.name != null
48
+ ? { name: previous.toolCall.name }
49
+ : {}),
50
+ ...(toolCall.rawInput === undefined &&
51
+ previous?.toolCall.rawInput !== undefined
52
+ ? { rawInput: previous.toolCall.rawInput }
53
+ : {}),
54
+ ...(toolCall.title == null && previous?.toolCall.title != null
55
+ ? { title: previous.toolCall.title }
56
+ : {}),
57
+ ...(toolCall._meta == null && previous?.toolCall._meta != null
58
+ ? { _meta: previous.toolCall._meta }
59
+ : {}),
60
+ };
61
+ const observed: ObservedCall = {
62
+ toolCall: merged,
63
+ consumed: false,
64
+ terminal: merged.status === 'completed' || merged.status === 'failed',
65
+ };
66
+ observedCalls.set(toolCall.toolCallId, observed);
67
+ if (observed.terminal) {
68
+ authorizations.delete(toolCall.toolCallId);
69
+ return;
70
+ }
71
+
72
+ const call = resolveHostToolCall({
73
+ toolCall: merged,
74
+ serverName,
75
+ toolNames,
76
+ });
77
+ if (call == null) {
78
+ authorizations.delete(toolCall.toolCallId);
79
+ return;
80
+ }
81
+ const key = callKey(call);
82
+ authorizations.set(toolCall.toolCallId, key);
83
+ const pendingIndex = pendingRequests.findIndex(
84
+ request => request.key === key,
85
+ );
86
+ if (pendingIndex !== -1) {
87
+ const [pending] = pendingRequests.splice(pendingIndex, 1);
88
+ clearTimeout(pending.timeout);
89
+ authorizations.delete(toolCall.toolCallId);
90
+ observed.consumed = true;
91
+ pending.resolve(true);
92
+ }
93
+ };
94
+
95
+ return {
96
+ observeUpdate: ({ update }) => {
97
+ if (
98
+ update.sessionUpdate === 'tool_call' ||
99
+ update.sessionUpdate === 'tool_call_update'
100
+ ) {
101
+ observe({ toolCall: update });
102
+ }
103
+ },
104
+ observeAllowedPermission: ({ toolCall }) => observe({ toolCall }),
105
+ waitForToolCallAuthorization: ({ toolName, input }) => {
106
+ if (closed) return Promise.resolve(false);
107
+ const key = callKey({ toolName, input });
108
+ for (const [toolCallId, authorizationKey] of authorizations) {
109
+ if (authorizationKey !== key) continue;
110
+ authorizations.delete(toolCallId);
111
+ observedCalls.get(toolCallId)!.consumed = true;
112
+ return Promise.resolve(true);
113
+ }
114
+ return new Promise(resolve => {
115
+ const pending = {
116
+ key,
117
+ timeout: setTimeout(() => {
118
+ const index = pendingRequests.indexOf(pending);
119
+ if (index !== -1) pendingRequests.splice(index, 1);
120
+ resolve(false);
121
+ }, ttlMs),
122
+ resolve,
123
+ };
124
+ pendingRequests.push(pending);
125
+ });
126
+ },
127
+ close: () => {
128
+ if (closed) return;
129
+ closed = true;
130
+ observedCalls.clear();
131
+ authorizations.clear();
132
+ for (const request of pendingRequests.splice(0)) {
133
+ clearTimeout(request.timeout);
134
+ request.resolve(false);
135
+ }
136
+ },
137
+ };
138
+ }
139
+
140
+ function resolveHostToolCall({
141
+ toolCall,
142
+ serverName,
143
+ toolNames,
144
+ }: {
145
+ toolCall: ToolCallUpdate;
146
+ serverName: string;
147
+ toolNames: ReadonlyArray<string>;
148
+ }): HostToolCall | undefined {
149
+ const rawInput = toolCall.rawInput;
150
+ if (!isRecord(rawInput)) return undefined;
151
+ const isDeferred = 'tool_name' in rawInput;
152
+ const isProvider =
153
+ 'providerIdentifier' in rawInput &&
154
+ 'toolName' in rawInput &&
155
+ 'args' in rawInput;
156
+ const isOrigin =
157
+ 'origin' in rawInput && 'operation' in rawInput && 'arguments' in rawInput;
158
+ const isCodex =
159
+ 'server' in rawInput && 'tool' in rawInput && 'arguments' in rawInput;
160
+ if ([isDeferred, isProvider, isOrigin, isCodex].filter(Boolean).length > 1) {
161
+ return undefined;
162
+ }
163
+ const matches: HostToolCall[] = [];
164
+ for (const toolName of toolNames) {
165
+ const qualifiedNames = [
166
+ `mcp__${serverName}__${toolName}`,
167
+ `${serverName}__${toolName}`,
168
+ `mcp_${serverName}_${toolName}`,
169
+ ];
170
+ const isQualified = (value: unknown) =>
171
+ typeof value === 'string' && qualifiedNames.includes(value);
172
+ const isDirect =
173
+ toolCall.name === toolName &&
174
+ isRecord(toolCall._meta) &&
175
+ toolCall._meta.serverName === serverName;
176
+ if (
177
+ isDeferred &&
178
+ isQualified(rawInput.tool_name) &&
179
+ isRecord(rawInput.tool_input) &&
180
+ (toolCall.name == null ||
181
+ toolCall.name === 'use_tool' ||
182
+ isQualified(toolCall.name))
183
+ ) {
184
+ matches.push({ toolName, input: rawInput.tool_input });
185
+ } else if (
186
+ isProvider &&
187
+ rawInput.providerIdentifier === serverName &&
188
+ rawInput.toolName === toolName &&
189
+ isRecord(rawInput.args) &&
190
+ (toolCall.name == null || isQualified(toolCall.name))
191
+ ) {
192
+ matches.push({ toolName, input: rawInput.args });
193
+ } else if (
194
+ isCodex &&
195
+ rawInput.server === serverName &&
196
+ rawInput.tool === toolName &&
197
+ isRecord(rawInput.arguments) &&
198
+ (toolCall.name == null || isQualified(toolCall.name))
199
+ ) {
200
+ matches.push({ toolName, input: rawInput.arguments });
201
+ } else if (
202
+ isOrigin &&
203
+ rawInput.origin === serverName &&
204
+ rawInput.operation === toolName &&
205
+ isRecord(rawInput.arguments) &&
206
+ (toolCall.name == null ||
207
+ toolCall.name === toolName ||
208
+ isQualified(toolCall.name))
209
+ ) {
210
+ matches.push({ toolName, input: rawInput.arguments });
211
+ } else if (
212
+ !isDeferred &&
213
+ !isProvider &&
214
+ !isOrigin &&
215
+ !isCodex &&
216
+ (isDirect ||
217
+ isQualified(toolCall.name) ||
218
+ (toolCall.name == null &&
219
+ (isQualified(toolCall.title) ||
220
+ toolCall.title === `${serverName}-${toolName}`)))
221
+ ) {
222
+ matches.push({ toolName, input: rawInput });
223
+ }
224
+ }
225
+ return matches.length === 1 ? matches[0] : undefined;
226
+ }
227
+
228
+ function callKey({ toolName, input }: HostToolCall): string {
229
+ return `${toolName}\0${canonicalFingerprint({ value: input })}`;
230
+ }
231
+
232
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
233
+ return value != null && typeof value === 'object' && !Array.isArray(value);
234
+ }
@@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server } from 'node:http';
3
3
  import type { AddressInfo } from 'node:net';
4
4
  import type { HarnessV1BridgeToolWire } from '@ai-sdk/harness';
5
5
  import type { ACPHostToolMCPTransport } from '../acp-v1-settings';
6
+ import { canonicalFingerprint } from './canonical-json-fingerprint';
6
7
  import {
7
8
  createHostToolMCPHttpEndpoint,
8
9
  HOST_TOOL_MCP_ENDPOINT_PATH,
@@ -18,6 +19,10 @@ export type HostToolCorrelationInvocation = {
18
19
  };
19
20
 
20
21
  export type HostToolRelayTurn = {
22
+ readonly waitForToolCallAuthorization: (options: {
23
+ toolName: string;
24
+ input: Readonly<Record<string, unknown>>;
25
+ }) => Promise<boolean>;
21
26
  readonly emitToolCall: (options: {
22
27
  toolCallId: string;
23
28
  toolName: string;
@@ -384,6 +389,18 @@ async function handleInvocation({
384
389
  });
385
390
  }
386
391
 
392
+ if (
393
+ !(await turn.waitForToolCallAuthorization({
394
+ toolName: tool.name,
395
+ input: body.input,
396
+ }))
397
+ ) {
398
+ throw new RelayRequestError({
399
+ status: 401,
400
+ message: 'Unauthorized host tool relay request.',
401
+ });
402
+ }
403
+
387
404
  const correlationToken = randomBytes(32).toString('hex');
388
405
  turn.registerCorrelationInvocation({
389
406
  token: correlationToken,
@@ -494,20 +511,7 @@ export function catalogFingerprint({
494
511
  }: {
495
512
  tools: ReadonlyArray<HarnessV1BridgeToolWire>;
496
513
  }): string {
497
- return JSON.stringify(canonicalizeJSON({ value: tools }));
498
- }
499
-
500
- function canonicalizeJSON({ value }: { value: unknown }): unknown {
501
- if (Array.isArray(value)) {
502
- return value.map(item => canonicalizeJSON({ value: item }));
503
- }
504
- if (!isRecord(value)) return value;
505
- return Object.fromEntries(
506
- Object.keys(value)
507
- .sort()
508
- .filter(key => value[key] !== undefined)
509
- .map(key => [key, canonicalizeJSON({ value: value[key] })]),
510
- );
514
+ return canonicalFingerprint({ value: tools });
511
515
  }
512
516
 
513
517
  async function readJSONBody({
@@ -37,6 +37,7 @@ import {
37
37
  type HostToolRelay,
38
38
  type HostToolRelayTurn,
39
39
  } from './host-tool-relay';
40
+ import { createHostToolRelayAuthorization } from './host-tool-relay-authorization';
40
41
  import { createHostToolMcpServerDefinition } from './host-tool-mcp-definition';
41
42
  import {
42
43
  promptAndRefreshInitialHostToolCatalog,
@@ -236,9 +237,14 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
236
237
  void cancellationFailure.catch(() => {});
237
238
  let cancellationRequested = false;
238
239
  let cancellationFailureError: Error | undefined;
240
+ const hostToolAuthorization = createHostToolRelayAuthorization({
241
+ serverName: HOST_TOOL_MCP_SERVER_NAME,
242
+ toolNames: (start.tools ?? []).map(tool => tool.name),
243
+ });
239
244
  const cancel = async () => {
240
245
  if (cancellationRequested) return;
241
246
  cancellationRequested = true;
247
+ hostToolAuthorization.close();
242
248
  activePermissionController?.cancelAll();
243
249
  try {
244
250
  if (connection == null) {
@@ -296,9 +302,12 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
296
302
  hasPermissionModeMapping: start.permissionModeMapping != null,
297
303
  emitToolCall: emitStreamEvent.permissionToolCall,
298
304
  claimHostToolPermission: emitStreamEvent.claimHostToolPermission,
305
+ onHostToolPermissionAllowed: hostToolAuthorization.observeAllowedPermission,
299
306
  });
300
307
  activePermissionController = permissionController;
301
308
  const relayTurn: HostToolRelayTurn = {
309
+ waitForToolCallAuthorization:
310
+ hostToolAuthorization.waitForToolCallAuthorization,
302
311
  emitToolCall: emitStreamEvent.hostToolCall,
303
312
  emitToolResult: emitStreamEvent.hostToolResult,
304
313
  requestToolResult: toolCallId => turn.requestToolResult(toolCallId),
@@ -366,6 +375,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
366
375
  const captured = streamCapture?.takeForUpdate({
367
376
  update: message.update,
368
377
  });
378
+ hostToolAuthorization.observeUpdate({ update: message.update });
369
379
  for (const rawValue of captured?.precedingRawValues ?? []) {
370
380
  emitStreamEvent.raw({ rawValue });
371
381
  }
@@ -381,6 +391,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
381
391
  if (emitStreamEvent.message({ message })) return;
382
392
  }
383
393
  } finally {
394
+ hostToolAuthorization.close();
384
395
  if (activeQuestionRequest?.turn === turn) {
385
396
  activeQuestionRequest = undefined;
386
397
  }
@@ -19,6 +19,7 @@ export function createACPPermissionController({
19
19
  hasPermissionModeMapping,
20
20
  emitToolCall,
21
21
  claimHostToolPermission,
22
+ onHostToolPermissionAllowed,
22
23
  }: {
23
24
  turn: BridgeTurn;
24
25
  sessionId: string;
@@ -26,6 +27,7 @@ export function createACPPermissionController({
26
27
  hasPermissionModeMapping: boolean;
27
28
  emitToolCall: (options: { toolCall: ToolCallUpdate }) => void;
28
29
  claimHostToolPermission: (options: { toolCall: ToolCallUpdate }) => boolean;
30
+ onHostToolPermissionAllowed?: (options: { toolCall: ToolCallUpdate }) => void;
29
31
  }): {
30
32
  requestPermission(
31
33
  request: RequestPermissionRequest,
@@ -63,6 +65,7 @@ export function createACPPermissionController({
63
65
  return cancelled();
64
66
  }
65
67
  if (claimHostToolPermission({ toolCall: request.toolCall })) {
68
+ onHostToolPermissionAllowed?.({ toolCall: request.toolCall });
66
69
  return {
67
70
  outcome: {
68
71
  outcome: 'selected',