@ai-sdk/harness-acp 0.0.0 → 1.0.1
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 +24 -0
- package/LICENSE +13 -0
- package/README.md +69 -3
- package/dist/bridge/host-tool-mcp.mjs +302 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +3317 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +12 -0
- package/dist/bridge/pnpm-lock.yaml +816 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +2089 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -10
- package/src/acp-auth.ts +254 -0
- package/src/acp-bridge-protocol.ts +13 -0
- package/src/acp-harness.ts +107 -0
- package/src/index.ts +19 -0
- package/src/v1/acp-v1-bridge-protocol.ts +151 -0
- package/src/v1/acp-v1-harness.ts +1461 -0
- package/src/v1/acp-v1-lifecycle.ts +115 -0
- package/src/v1/acp-v1-prompt.ts +122 -0
- package/src/v1/acp-v1-settings.ts +97 -0
- package/src/v1/acp-v1-skills.ts +228 -0
- package/src/v1/acp-v1-turn-start-config.ts +93 -0
- package/src/v1/bridge/acp-diagnostics.ts +83 -0
- package/src/v1/bridge/acp-stream-capture.ts +152 -0
- package/src/v1/bridge/acp-v1-bridge-environment.ts +95 -0
- package/src/v1/bridge/agent-stderr-monitor.ts +52 -0
- package/src/v1/bridge/create-emit-stream-event.ts +97 -0
- package/src/v1/bridge/host-tool-correlation.ts +640 -0
- package/src/v1/bridge/host-tool-mcp-server.ts +180 -0
- package/src/v1/bridge/host-tool-mcp.ts +185 -0
- package/src/v1/bridge/host-tool-relay-client.ts +53 -0
- package/src/v1/bridge/host-tool-relay.ts +510 -0
- package/src/v1/bridge/index.ts +714 -0
- package/src/v1/bridge/package.json +12 -0
- package/src/v1/bridge/permission-controller.ts +152 -0
- package/src/v1/bridge/permission-mode.ts +163 -0
- package/src/v1/bridge/pnpm-lock.yaml +816 -0
- package/src/v1/bridge/profile-values.ts +97 -0
- package/src/v1/bridge/protocol-configuration.ts +146 -0
- package/src/v1/bridge/recovered-session.ts +145 -0
- package/src/v1/bridge/refresh-host-tool-catalog.ts +33 -0
- package/src/v1/bridge/session-lifecycle.ts +83 -0
- package/src/v1/bridge/stream-translator.ts +901 -0
- package/src/v1/implementation.ts +308 -0
- package/src/v1/index.ts +16 -0
- package/src/version.ts +6 -0
- package/index.js +0 -1
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
|
3
|
+
import type { AddressInfo } from 'node:net';
|
|
4
|
+
import type { HarnessV1BridgeToolWire } from '@ai-sdk/harness';
|
|
5
|
+
|
|
6
|
+
export type HostToolCorrelationInvocation = {
|
|
7
|
+
readonly token: string;
|
|
8
|
+
readonly serverName: string;
|
|
9
|
+
readonly toolName: string;
|
|
10
|
+
readonly input: Readonly<Record<string, unknown>>;
|
|
11
|
+
readonly order: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type HostToolRelayTurn = {
|
|
15
|
+
readonly emitToolCall: (options: {
|
|
16
|
+
toolCallId: string;
|
|
17
|
+
toolName: string;
|
|
18
|
+
input: Readonly<Record<string, unknown>>;
|
|
19
|
+
}) => void;
|
|
20
|
+
readonly emitToolResult: (options: {
|
|
21
|
+
toolCallId: string;
|
|
22
|
+
toolName: string;
|
|
23
|
+
output: unknown;
|
|
24
|
+
isError?: boolean;
|
|
25
|
+
}) => void;
|
|
26
|
+
readonly requestToolResult: (
|
|
27
|
+
toolCallId: string,
|
|
28
|
+
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
29
|
+
readonly registerCorrelationInvocation: (
|
|
30
|
+
options: HostToolCorrelationInvocation,
|
|
31
|
+
) => void;
|
|
32
|
+
readonly removeCorrelationInvocation: (options: { token: string }) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type HostToolRelay = {
|
|
36
|
+
readonly url: string;
|
|
37
|
+
readonly credential: string;
|
|
38
|
+
bindTurn(options: { turn: HostToolRelayTurn }): void;
|
|
39
|
+
unbindTurn(options: { turn: HostToolRelayTurn }): void;
|
|
40
|
+
updateCatalog(options: { tools: ReadonlyArray<HarnessV1BridgeToolWire> }): {
|
|
41
|
+
changed: boolean;
|
|
42
|
+
revision: number;
|
|
43
|
+
};
|
|
44
|
+
waitForCatalogRefresh(options: {
|
|
45
|
+
revision: number;
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
}): Promise<boolean>;
|
|
48
|
+
close(): Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type CatalogState = {
|
|
52
|
+
tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
53
|
+
fingerprint: string;
|
|
54
|
+
revision: number;
|
|
55
|
+
servedRevision: number;
|
|
56
|
+
closed: boolean;
|
|
57
|
+
readonly changeWaiters: Set<() => void>;
|
|
58
|
+
readonly refreshWaiters: Set<{
|
|
59
|
+
readonly revision: number;
|
|
60
|
+
readonly resolve: (refreshed: boolean) => void;
|
|
61
|
+
}>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export async function startHostToolRelay({
|
|
65
|
+
tools,
|
|
66
|
+
serverName,
|
|
67
|
+
}: {
|
|
68
|
+
tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
69
|
+
serverName: string;
|
|
70
|
+
}): Promise<HostToolRelay> {
|
|
71
|
+
const state: CatalogState = {
|
|
72
|
+
tools: [...tools],
|
|
73
|
+
fingerprint: catalogFingerprint({ tools }),
|
|
74
|
+
revision: 1,
|
|
75
|
+
servedRevision: 0,
|
|
76
|
+
closed: false,
|
|
77
|
+
changeWaiters: new Set(),
|
|
78
|
+
refreshWaiters: new Set(),
|
|
79
|
+
};
|
|
80
|
+
const credential = randomBytes(32).toString('hex');
|
|
81
|
+
let activeTurn: HostToolRelayTurn | undefined;
|
|
82
|
+
let invocationOrder = 0;
|
|
83
|
+
let closePromise: Promise<void> | undefined;
|
|
84
|
+
const server = createServer(async (request, response) => {
|
|
85
|
+
try {
|
|
86
|
+
const result = await handleRequest({
|
|
87
|
+
request,
|
|
88
|
+
credential,
|
|
89
|
+
state,
|
|
90
|
+
serverName,
|
|
91
|
+
turn: activeTurn,
|
|
92
|
+
nextInvocationOrder: () => ++invocationOrder,
|
|
93
|
+
});
|
|
94
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
95
|
+
response.end(JSON.stringify(result));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const status = error instanceof RelayRequestError ? error.status : 500;
|
|
98
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
99
|
+
response.end(
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
error: error instanceof Error ? error.message : String(error),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
await listen({ server });
|
|
107
|
+
const address = server.address() as AddressInfo;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
url: `http://127.0.0.1:${address.port}/invoke`,
|
|
111
|
+
credential,
|
|
112
|
+
bindTurn: ({ turn }) => {
|
|
113
|
+
if (activeTurn != null && activeTurn !== turn) {
|
|
114
|
+
throw new Error('A host tool relay turn is already active.');
|
|
115
|
+
}
|
|
116
|
+
activeTurn = turn;
|
|
117
|
+
},
|
|
118
|
+
unbindTurn: ({ turn }) => {
|
|
119
|
+
if (activeTurn === turn) activeTurn = undefined;
|
|
120
|
+
},
|
|
121
|
+
updateCatalog: ({ tools: nextTools }) => {
|
|
122
|
+
const fingerprint = catalogFingerprint({ tools: nextTools });
|
|
123
|
+
if (fingerprint === state.fingerprint) {
|
|
124
|
+
return { changed: false, revision: state.revision };
|
|
125
|
+
}
|
|
126
|
+
state.tools = [...nextTools];
|
|
127
|
+
state.fingerprint = fingerprint;
|
|
128
|
+
state.revision += 1;
|
|
129
|
+
resolveCatalogChanges({ state });
|
|
130
|
+
return { changed: true, revision: state.revision };
|
|
131
|
+
},
|
|
132
|
+
waitForCatalogRefresh: ({ revision, timeoutMs }) =>
|
|
133
|
+
waitForCatalogRefresh({ state, revision, timeoutMs }),
|
|
134
|
+
close: () => {
|
|
135
|
+
if (closePromise != null) return closePromise;
|
|
136
|
+
state.closed = true;
|
|
137
|
+
resolveCatalogChanges({ state });
|
|
138
|
+
resolveRefreshWaiters({ state, closing: true });
|
|
139
|
+
closePromise = closeServer({ server });
|
|
140
|
+
return closePromise;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleRequest({
|
|
146
|
+
request,
|
|
147
|
+
credential,
|
|
148
|
+
state,
|
|
149
|
+
serverName,
|
|
150
|
+
turn,
|
|
151
|
+
nextInvocationOrder,
|
|
152
|
+
}: {
|
|
153
|
+
request: IncomingMessage;
|
|
154
|
+
credential: string;
|
|
155
|
+
state: CatalogState;
|
|
156
|
+
serverName: string;
|
|
157
|
+
turn: HostToolRelayTurn | undefined;
|
|
158
|
+
nextInvocationOrder: () => number;
|
|
159
|
+
}): Promise<unknown> {
|
|
160
|
+
if (request.method !== 'POST') {
|
|
161
|
+
throw new RelayRequestError({
|
|
162
|
+
status: 404,
|
|
163
|
+
message: 'Unknown host tool relay endpoint.',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
!credentialsMatch({
|
|
168
|
+
expected: credential,
|
|
169
|
+
actual: request.headers.authorization,
|
|
170
|
+
})
|
|
171
|
+
) {
|
|
172
|
+
throw new RelayRequestError({
|
|
173
|
+
status: 401,
|
|
174
|
+
message: 'Invalid host tool relay credential.',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const body = await readJSONBody({ request });
|
|
178
|
+
if (request.url === '/catalog/next') {
|
|
179
|
+
return handleCatalogNext({ body, state });
|
|
180
|
+
}
|
|
181
|
+
if (request.url === '/catalog/seen') {
|
|
182
|
+
return handleCatalogSeen({ body, state });
|
|
183
|
+
}
|
|
184
|
+
if (request.url === '/invoke') {
|
|
185
|
+
return handleInvocation({
|
|
186
|
+
body,
|
|
187
|
+
state,
|
|
188
|
+
serverName,
|
|
189
|
+
turn,
|
|
190
|
+
nextInvocationOrder,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
throw new RelayRequestError({
|
|
194
|
+
status: 404,
|
|
195
|
+
message: 'Unknown host tool relay endpoint.',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function handleCatalogNext({
|
|
200
|
+
body,
|
|
201
|
+
state,
|
|
202
|
+
}: {
|
|
203
|
+
body: unknown;
|
|
204
|
+
state: CatalogState;
|
|
205
|
+
}): Promise<unknown> {
|
|
206
|
+
if (
|
|
207
|
+
!isRecord(body) ||
|
|
208
|
+
!Number.isSafeInteger(body.afterRevision) ||
|
|
209
|
+
(body.afterRevision as number) < 0
|
|
210
|
+
) {
|
|
211
|
+
throw new RelayRequestError({
|
|
212
|
+
status: 400,
|
|
213
|
+
message: 'Invalid host tool catalog poll request.',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const afterRevision = body.afterRevision as number;
|
|
217
|
+
if (!state.closed && afterRevision >= state.revision) {
|
|
218
|
+
await waitForCatalogChange({ state });
|
|
219
|
+
}
|
|
220
|
+
if (state.closed) {
|
|
221
|
+
return { closed: true, revision: state.revision };
|
|
222
|
+
}
|
|
223
|
+
return afterRevision < state.revision
|
|
224
|
+
? { revision: state.revision, tools: state.tools }
|
|
225
|
+
: { revision: state.revision };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleCatalogSeen({
|
|
229
|
+
body,
|
|
230
|
+
state,
|
|
231
|
+
}: {
|
|
232
|
+
body: unknown;
|
|
233
|
+
state: CatalogState;
|
|
234
|
+
}): unknown {
|
|
235
|
+
if (
|
|
236
|
+
!isRecord(body) ||
|
|
237
|
+
!Number.isSafeInteger(body.revision) ||
|
|
238
|
+
(body.revision as number) < 1 ||
|
|
239
|
+
(body.revision as number) > state.revision
|
|
240
|
+
) {
|
|
241
|
+
throw new RelayRequestError({
|
|
242
|
+
status: 400,
|
|
243
|
+
message: 'Invalid host tool catalog acknowledgment.',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
state.servedRevision = Math.max(
|
|
247
|
+
state.servedRevision,
|
|
248
|
+
body.revision as number,
|
|
249
|
+
);
|
|
250
|
+
resolveRefreshWaiters({ state, closing: false });
|
|
251
|
+
return { acknowledged: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function handleInvocation({
|
|
255
|
+
body,
|
|
256
|
+
state,
|
|
257
|
+
serverName,
|
|
258
|
+
turn,
|
|
259
|
+
nextInvocationOrder,
|
|
260
|
+
}: {
|
|
261
|
+
body: unknown;
|
|
262
|
+
state: CatalogState;
|
|
263
|
+
serverName: string;
|
|
264
|
+
turn: HostToolRelayTurn | undefined;
|
|
265
|
+
nextInvocationOrder: () => number;
|
|
266
|
+
}): Promise<{
|
|
267
|
+
output: unknown;
|
|
268
|
+
isError?: boolean;
|
|
269
|
+
correlationToken: string;
|
|
270
|
+
}> {
|
|
271
|
+
if (turn == null) {
|
|
272
|
+
throw new RelayRequestError({
|
|
273
|
+
status: 409,
|
|
274
|
+
message: 'No ACP prompt turn is active.',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
if (
|
|
278
|
+
!isRecord(body) ||
|
|
279
|
+
typeof body.requestId !== 'string' ||
|
|
280
|
+
typeof body.toolName !== 'string' ||
|
|
281
|
+
!isRecord(body.input) ||
|
|
282
|
+
!Number.isSafeInteger(body.catalogRevision)
|
|
283
|
+
) {
|
|
284
|
+
throw new RelayRequestError({
|
|
285
|
+
status: 400,
|
|
286
|
+
message: 'Invalid host tool relay request.',
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (body.catalogRevision !== state.revision) {
|
|
290
|
+
throw new RelayRequestError({
|
|
291
|
+
status: 409,
|
|
292
|
+
message:
|
|
293
|
+
`Host tool ${body.toolName} was invoked from stale catalog revision ` +
|
|
294
|
+
`${body.catalogRevision}; the active revision is ${state.revision}.`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const tool = state.tools.find(item => item.name === body.toolName);
|
|
298
|
+
if (tool == null) {
|
|
299
|
+
throw new RelayRequestError({
|
|
300
|
+
status: 404,
|
|
301
|
+
message:
|
|
302
|
+
`Host tool ${body.toolName} is not active in catalog revision ` +
|
|
303
|
+
`${state.revision}.`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const correlationToken = randomBytes(32).toString('hex');
|
|
308
|
+
turn.registerCorrelationInvocation({
|
|
309
|
+
token: correlationToken,
|
|
310
|
+
serverName,
|
|
311
|
+
toolName: tool.name,
|
|
312
|
+
input: body.input,
|
|
313
|
+
order: nextInvocationOrder(),
|
|
314
|
+
});
|
|
315
|
+
turn.emitToolCall({
|
|
316
|
+
toolCallId: body.requestId,
|
|
317
|
+
toolName: tool.name,
|
|
318
|
+
input: body.input,
|
|
319
|
+
});
|
|
320
|
+
let result: { output: unknown; isError?: boolean };
|
|
321
|
+
try {
|
|
322
|
+
result = await turn.requestToolResult(body.requestId);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
turn.removeCorrelationInvocation({ token: correlationToken });
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
turn.emitToolResult({
|
|
328
|
+
toolCallId: body.requestId,
|
|
329
|
+
toolName: tool.name,
|
|
330
|
+
output: result.output,
|
|
331
|
+
...(result.isError ? { isError: true } : {}),
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
output: result.output,
|
|
335
|
+
...(result.isError ? { isError: true } : {}),
|
|
336
|
+
correlationToken,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function waitForCatalogChange({
|
|
341
|
+
state,
|
|
342
|
+
}: {
|
|
343
|
+
state: CatalogState;
|
|
344
|
+
}): Promise<void> {
|
|
345
|
+
let changeWaiter!: () => void;
|
|
346
|
+
const change = new Promise<void>(resolve => {
|
|
347
|
+
changeWaiter = resolve;
|
|
348
|
+
state.changeWaiters.add(changeWaiter);
|
|
349
|
+
});
|
|
350
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
351
|
+
const timeout = new Promise<void>(resolve => {
|
|
352
|
+
timer = setTimeout(resolve, 20_000);
|
|
353
|
+
timer.unref?.();
|
|
354
|
+
});
|
|
355
|
+
return Promise.race([change, timeout]).finally(() => {
|
|
356
|
+
state.changeWaiters.delete(changeWaiter);
|
|
357
|
+
if (timer != null) clearTimeout(timer);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function resolveCatalogChanges({ state }: { state: CatalogState }): void {
|
|
362
|
+
for (const resolve of [...state.changeWaiters]) resolve();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function waitForCatalogRefresh({
|
|
366
|
+
state,
|
|
367
|
+
revision,
|
|
368
|
+
timeoutMs,
|
|
369
|
+
}: {
|
|
370
|
+
state: CatalogState;
|
|
371
|
+
revision: number;
|
|
372
|
+
timeoutMs: number;
|
|
373
|
+
}): Promise<boolean> {
|
|
374
|
+
if (state.servedRevision >= revision) return Promise.resolve(true);
|
|
375
|
+
if (state.closed) return Promise.resolve(false);
|
|
376
|
+
let waiter!: {
|
|
377
|
+
readonly revision: number;
|
|
378
|
+
readonly resolve: (refreshed: boolean) => void;
|
|
379
|
+
};
|
|
380
|
+
const refresh = new Promise<boolean>(resolve => {
|
|
381
|
+
waiter = {
|
|
382
|
+
revision,
|
|
383
|
+
resolve,
|
|
384
|
+
};
|
|
385
|
+
state.refreshWaiters.add(waiter);
|
|
386
|
+
});
|
|
387
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
388
|
+
const timeout = new Promise<boolean>(resolve => {
|
|
389
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
390
|
+
timer.unref?.();
|
|
391
|
+
});
|
|
392
|
+
return Promise.race([refresh, timeout]).finally(() => {
|
|
393
|
+
state.refreshWaiters.delete(waiter);
|
|
394
|
+
if (timer != null) clearTimeout(timer);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function resolveRefreshWaiters({
|
|
399
|
+
state,
|
|
400
|
+
closing,
|
|
401
|
+
}: {
|
|
402
|
+
state: CatalogState;
|
|
403
|
+
closing: boolean;
|
|
404
|
+
}): void {
|
|
405
|
+
for (const waiter of [...state.refreshWaiters]) {
|
|
406
|
+
if (closing || state.servedRevision >= waiter.revision) {
|
|
407
|
+
waiter.resolve(!closing);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function catalogFingerprint({
|
|
413
|
+
tools,
|
|
414
|
+
}: {
|
|
415
|
+
tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
416
|
+
}): string {
|
|
417
|
+
return JSON.stringify(canonicalizeJSON({ value: tools }));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function canonicalizeJSON({ value }: { value: unknown }): unknown {
|
|
421
|
+
if (Array.isArray(value)) {
|
|
422
|
+
return value.map(item => canonicalizeJSON({ value: item }));
|
|
423
|
+
}
|
|
424
|
+
if (!isRecord(value)) return value;
|
|
425
|
+
return Object.fromEntries(
|
|
426
|
+
Object.keys(value)
|
|
427
|
+
.sort()
|
|
428
|
+
.filter(key => value[key] !== undefined)
|
|
429
|
+
.map(key => [key, canonicalizeJSON({ value: value[key] })]),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function readJSONBody({
|
|
434
|
+
request,
|
|
435
|
+
}: {
|
|
436
|
+
request: IncomingMessage;
|
|
437
|
+
}): Promise<unknown> {
|
|
438
|
+
const chunks: Buffer[] = [];
|
|
439
|
+
let size = 0;
|
|
440
|
+
for await (const chunk of request) {
|
|
441
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
442
|
+
size += buffer.length;
|
|
443
|
+
if (size > 16 * 1024 * 1024) {
|
|
444
|
+
throw new RelayRequestError({
|
|
445
|
+
status: 413,
|
|
446
|
+
message: 'Host tool relay request is too large.',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
chunks.push(buffer);
|
|
450
|
+
}
|
|
451
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
452
|
+
try {
|
|
453
|
+
return await new Response(text, {
|
|
454
|
+
headers: { 'content-type': 'application/json' },
|
|
455
|
+
}).json();
|
|
456
|
+
} catch {
|
|
457
|
+
throw new RelayRequestError({
|
|
458
|
+
status: 400,
|
|
459
|
+
message: 'Host tool relay request is not valid JSON.',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function credentialsMatch({
|
|
465
|
+
expected,
|
|
466
|
+
actual,
|
|
467
|
+
}: {
|
|
468
|
+
expected: string;
|
|
469
|
+
actual: string | undefined;
|
|
470
|
+
}): boolean {
|
|
471
|
+
const expectedValue = Buffer.from(`Bearer ${expected}`);
|
|
472
|
+
const actualValue = Buffer.from(actual ?? '');
|
|
473
|
+
return (
|
|
474
|
+
expectedValue.length === actualValue.length &&
|
|
475
|
+
timingSafeEqual(expectedValue, actualValue)
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function listen({ server }: { server: Server }): Promise<void> {
|
|
480
|
+
return new Promise((resolve, reject) => {
|
|
481
|
+
server.once('error', reject);
|
|
482
|
+
server.listen(0, '127.0.0.1', () => {
|
|
483
|
+
server.off('error', reject);
|
|
484
|
+
resolve();
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function closeServer({ server }: { server: Server }): Promise<void> {
|
|
490
|
+
return new Promise((resolve, reject) => {
|
|
491
|
+
server.close(error => {
|
|
492
|
+
if (error == null) resolve();
|
|
493
|
+
else reject(error);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
499
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
class RelayRequestError extends Error {
|
|
503
|
+
readonly status: number;
|
|
504
|
+
|
|
505
|
+
constructor({ status, message }: { status: number; message: string }) {
|
|
506
|
+
super(message);
|
|
507
|
+
this.name = 'HostToolRelayRequestError';
|
|
508
|
+
this.status = status;
|
|
509
|
+
}
|
|
510
|
+
}
|