@zhin.js/a2a 3.0.13 → 3.0.15
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 +60 -0
- package/README.md +72 -0
- package/lib/agent-executor.js +1 -0
- package/lib/agent-executor.js.map +1 -1
- package/lib/index.d.ts +4 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -1
- package/lib/runtime.d.ts +21 -0
- package/lib/runtime.d.ts.map +1 -1
- package/lib/runtime.js +3 -0
- package/lib/runtime.js.map +1 -1
- package/lib/workroom-auth-registry.d.ts +66 -0
- package/lib/workroom-auth-registry.d.ts.map +1 -0
- package/lib/workroom-auth-registry.js +210 -0
- package/lib/workroom-auth-registry.js.map +1 -0
- package/lib/workroom-callback-runtime.d.ts +43 -0
- package/lib/workroom-callback-runtime.d.ts.map +1 -0
- package/lib/workroom-callback-runtime.js +216 -0
- package/lib/workroom-callback-runtime.js.map +1 -0
- package/lib/workroom-remote-transport.d.ts +58 -0
- package/lib/workroom-remote-transport.d.ts.map +1 -0
- package/lib/workroom-remote-transport.js +429 -0
- package/lib/workroom-remote-transport.js.map +1 -0
- package/package.json +7 -7
- package/src/agent-executor.ts +1 -0
- package/src/index.ts +5 -0
- package/src/runtime.ts +42 -0
- package/src/workroom-auth-registry.ts +279 -0
- package/src/workroom-callback-runtime.ts +294 -0
- package/src/workroom-remote-transport.ts +555 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemoteCallbackPollPort,
|
|
3
|
+
RemoteCallbackPollRequest,
|
|
4
|
+
RemoteCallbackPollSnapshot,
|
|
5
|
+
WORKROOM_A2A_EXTENSION_URI,
|
|
6
|
+
WorkroomRemoteDispatchOutboxItem,
|
|
7
|
+
} from '@zhin.js/agent';
|
|
8
|
+
import type {
|
|
9
|
+
WorkroomRemoteEndpointAuthority,
|
|
10
|
+
WorkroomRemoteEndpointAuthorityPort,
|
|
11
|
+
WorkroomRemoteDispatchObservation,
|
|
12
|
+
WorkroomRemoteExecutorPort,
|
|
13
|
+
} from '@zhin.js/agent/runtime';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { lookup } from 'node:dns/promises';
|
|
16
|
+
import * as http from 'node:http';
|
|
17
|
+
import * as https from 'node:https';
|
|
18
|
+
import { isIP } from 'node:net';
|
|
19
|
+
import type {
|
|
20
|
+
WorkroomA2aCredentialReference,
|
|
21
|
+
WorkroomA2aRegisteredAuthBindingSnapshot,
|
|
22
|
+
WorkroomA2aSecureCredentialProvider,
|
|
23
|
+
} from './workroom-auth-registry.js';
|
|
24
|
+
import { WorkroomA2aAuthRegistry } from './workroom-auth-registry.js';
|
|
25
|
+
|
|
26
|
+
// Keep the Host runtime dependency-neutral while the literal type remains
|
|
27
|
+
// checked against the Agent-owned Workroom protocol contract at compile time.
|
|
28
|
+
const WORKROOM_A2A_EXTENSION_URI_VALUE: typeof WORKROOM_A2A_EXTENSION_URI =
|
|
29
|
+
'https://zhin.dev/extensions/workroom-executor/v1';
|
|
30
|
+
|
|
31
|
+
export interface WorkroomA2aRemoteTransportBindingInput {
|
|
32
|
+
readonly endpointId: string;
|
|
33
|
+
readonly cardDigest: string;
|
|
34
|
+
readonly authBindingId: string;
|
|
35
|
+
readonly dispatchUrl: string;
|
|
36
|
+
readonly pollUrl: string;
|
|
37
|
+
readonly credential: WorkroomA2aCredentialReference;
|
|
38
|
+
readonly authority?: Readonly<{
|
|
39
|
+
readonly workroomExtension: typeof WORKROOM_A2A_EXTENSION_URI;
|
|
40
|
+
readonly idempotentDispatch: boolean;
|
|
41
|
+
readonly typedCompletionEnvelope: boolean;
|
|
42
|
+
readonly workspaceProviders: readonly string[];
|
|
43
|
+
}>;
|
|
44
|
+
readonly enabled: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WorkroomA2aHttpRemoteTransportOptions {
|
|
48
|
+
readonly authRegistry: WorkroomA2aAuthRegistry;
|
|
49
|
+
readonly callbackUrl: string;
|
|
50
|
+
readonly bindings: readonly WorkroomA2aRemoteTransportBindingInput[];
|
|
51
|
+
readonly secureCredentialProvider?: WorkroomA2aSecureCredentialProvider;
|
|
52
|
+
/** Trusted low-level seam. Production uses DNS validation plus a pinned Node socket. */
|
|
53
|
+
readonly network?: WorkroomA2aPinnedNetworkPort;
|
|
54
|
+
readonly maxResponseBytes?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkroomA2aResolvedAddress {
|
|
58
|
+
readonly address: string;
|
|
59
|
+
readonly family: 4 | 6;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WorkroomA2aPinnedRequest {
|
|
63
|
+
readonly url: URL;
|
|
64
|
+
readonly address: string;
|
|
65
|
+
readonly family: 4 | 6;
|
|
66
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
67
|
+
readonly body: string;
|
|
68
|
+
readonly signal: AbortSignal;
|
|
69
|
+
readonly maxResponseBytes: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WorkroomA2aPinnedNetworkPort {
|
|
73
|
+
resolve(hostname: string): Promise<readonly WorkroomA2aResolvedAddress[]>;
|
|
74
|
+
request(input: WorkroomA2aPinnedRequest): Promise<Response>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CompiledRemoteBinding {
|
|
78
|
+
readonly endpointId: string;
|
|
79
|
+
readonly cardDigest: string;
|
|
80
|
+
readonly authBindingId: string;
|
|
81
|
+
readonly dispatchUrl: string;
|
|
82
|
+
readonly pollUrl: string;
|
|
83
|
+
readonly authorization: string;
|
|
84
|
+
readonly enabled: boolean;
|
|
85
|
+
readonly authority?: WorkroomRemoteEndpointAuthority;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fixed-generation HTTP implementation of the Workroom A2A extension. It
|
|
90
|
+
* transports immutable envelopes and typed poll snapshots only; Task state
|
|
91
|
+
* remains owned by the local Workroom Kernel.
|
|
92
|
+
*/
|
|
93
|
+
export class WorkroomA2aHttpRemoteTransport
|
|
94
|
+
implements WorkroomRemoteExecutorPort, RemoteCallbackPollPort, WorkroomRemoteEndpointAuthorityPort {
|
|
95
|
+
readonly #authRegistry: WorkroomA2aAuthRegistry;
|
|
96
|
+
readonly #callbackUrl: string;
|
|
97
|
+
readonly #bindings: ReadonlyMap<string, CompiledRemoteBinding>;
|
|
98
|
+
readonly #network: WorkroomA2aPinnedNetworkPort;
|
|
99
|
+
readonly #maxResponseBytes: number;
|
|
100
|
+
|
|
101
|
+
constructor(options: WorkroomA2aHttpRemoteTransportOptions) {
|
|
102
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
103
|
+
throw new Error('Workroom A2A remote transport options must be an object');
|
|
104
|
+
}
|
|
105
|
+
this.#authRegistry = options.authRegistry;
|
|
106
|
+
this.#callbackUrl = canonicalUrl(options.callbackUrl, 'callbackUrl');
|
|
107
|
+
this.#network = options.network ?? new NodeWorkroomA2aPinnedNetwork();
|
|
108
|
+
this.#maxResponseBytes = positiveInteger(options.maxResponseBytes ?? 1_048_576, 'maxResponseBytes');
|
|
109
|
+
if (!Array.isArray(options.bindings) || options.bindings.length === 0) {
|
|
110
|
+
throw new Error('Workroom A2A remote transport requires endpoint bindings');
|
|
111
|
+
}
|
|
112
|
+
const bindings = new Map<string, CompiledRemoteBinding>();
|
|
113
|
+
for (const input of options.bindings) {
|
|
114
|
+
exactKeys(input, [
|
|
115
|
+
'endpointId', 'cardDigest', 'authBindingId', 'dispatchUrl', 'pollUrl',
|
|
116
|
+
'credential', 'authority', 'enabled',
|
|
117
|
+
], 'remote binding');
|
|
118
|
+
const endpointId = text(input.endpointId, 'endpointId');
|
|
119
|
+
if (bindings.has(endpointId)) throw new Error(`Duplicate Workroom A2A endpoint ${endpointId}`);
|
|
120
|
+
const callbackAuthority = options.authRegistry.snapshot.bindings.find(
|
|
121
|
+
candidate => candidate.endpointId === endpointId,
|
|
122
|
+
);
|
|
123
|
+
if (!callbackAuthority
|
|
124
|
+
|| callbackAuthority.cardDigest !== input.cardDigest
|
|
125
|
+
|| callbackAuthority.authBindingId !== input.authBindingId) {
|
|
126
|
+
throw new Error(`Workroom A2A endpoint ${endpointId} has no exact callback authority`);
|
|
127
|
+
}
|
|
128
|
+
const dispatchUrl = trustedDestinationUrl(
|
|
129
|
+
input.dispatchUrl,
|
|
130
|
+
'dispatchUrl',
|
|
131
|
+
callbackAuthority.trustDomain,
|
|
132
|
+
);
|
|
133
|
+
const pollUrl = trustedDestinationUrl(
|
|
134
|
+
input.pollUrl,
|
|
135
|
+
'pollUrl',
|
|
136
|
+
callbackAuthority.trustDomain,
|
|
137
|
+
);
|
|
138
|
+
if (new URL(dispatchUrl).origin !== new URL(pollUrl).origin) {
|
|
139
|
+
throw new Error(`Workroom A2A endpoint ${endpointId} dispatch/poll origin drift`);
|
|
140
|
+
}
|
|
141
|
+
const credential = resolveCredential(input.credential, options.secureCredentialProvider);
|
|
142
|
+
const authority = input.authority === undefined
|
|
143
|
+
? undefined
|
|
144
|
+
: compileEndpointAuthority(
|
|
145
|
+
input.authority,
|
|
146
|
+
callbackAuthority,
|
|
147
|
+
dispatchUrl,
|
|
148
|
+
pollUrl,
|
|
149
|
+
);
|
|
150
|
+
bindings.set(endpointId, Object.freeze({
|
|
151
|
+
endpointId,
|
|
152
|
+
cardDigest: digest(input.cardDigest, 'cardDigest'),
|
|
153
|
+
authBindingId: text(input.authBindingId, 'authBindingId'),
|
|
154
|
+
dispatchUrl,
|
|
155
|
+
pollUrl,
|
|
156
|
+
authorization: `Bearer ${credential}`,
|
|
157
|
+
enabled: boolean(input.enabled, 'enabled'),
|
|
158
|
+
...(authority === undefined ? {} : { authority }),
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
this.#bindings = bindings;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
resolve(endpointId: string): WorkroomRemoteEndpointAuthority | undefined {
|
|
165
|
+
const binding = this.#bindings.get(text(endpointId, 'endpointId'));
|
|
166
|
+
return binding?.enabled ? binding.authority : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async dispatch(
|
|
170
|
+
item: WorkroomRemoteDispatchOutboxItem,
|
|
171
|
+
signal: AbortSignal,
|
|
172
|
+
governedBody?: Uint8Array,
|
|
173
|
+
): Promise<WorkroomRemoteDispatchObservation> {
|
|
174
|
+
signal.throwIfAborted();
|
|
175
|
+
if (!governedBody) {
|
|
176
|
+
throw new Error('Workroom A2A governed disclosure body is unavailable');
|
|
177
|
+
}
|
|
178
|
+
const binding = this.#binding(
|
|
179
|
+
item.envelope.endpoint.id,
|
|
180
|
+
item.envelope.endpoint.cardDigest,
|
|
181
|
+
item.envelope.endpoint.authBindingId,
|
|
182
|
+
);
|
|
183
|
+
const response = await this.#request(binding.dispatchUrl, binding.authorization, {
|
|
184
|
+
version: 1,
|
|
185
|
+
callback: {
|
|
186
|
+
url: this.#callbackUrl,
|
|
187
|
+
authorization: this.#authRegistry.callbackAuthorization(binding.endpointId),
|
|
188
|
+
},
|
|
189
|
+
item,
|
|
190
|
+
governedPayload: {
|
|
191
|
+
version: 1,
|
|
192
|
+
manifestDigest: item.envelope.disclosureManifest.manifest.digest,
|
|
193
|
+
mediaType: 'application/octet-stream',
|
|
194
|
+
encoding: 'base64',
|
|
195
|
+
body: Buffer.from(governedBody).toString('base64'),
|
|
196
|
+
},
|
|
197
|
+
}, signal);
|
|
198
|
+
if (response.status >= 400 && response.status < 500) {
|
|
199
|
+
return Object.freeze({
|
|
200
|
+
outcome: 'failed',
|
|
201
|
+
receiptId: `workroom-http-rejected:v1:${encodeURIComponent(item.dispatchId)}:${response.status}`,
|
|
202
|
+
reason: `remote_http_${response.status}`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (!response.ok) throw new Error(`Workroom A2A dispatch HTTP ${response.status}`);
|
|
206
|
+
const value = await this.#json(response, 'dispatch receipt');
|
|
207
|
+
exactKeys(value, ['version', 'receiptId', 'remoteTaskId', 'remoteContextId'], 'dispatch receipt');
|
|
208
|
+
if (value.version !== 1) throw new Error('Workroom A2A dispatch receipt version is unsupported');
|
|
209
|
+
return Object.freeze({
|
|
210
|
+
outcome: 'delivered',
|
|
211
|
+
receiptId: text(value.receiptId, 'receiptId'),
|
|
212
|
+
remoteTaskId: text(value.remoteTaskId, 'remoteTaskId'),
|
|
213
|
+
remoteContextId: text(value.remoteContextId, 'remoteContextId'),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async poll(
|
|
218
|
+
request: RemoteCallbackPollRequest,
|
|
219
|
+
signal: AbortSignal,
|
|
220
|
+
): Promise<RemoteCallbackPollSnapshot> {
|
|
221
|
+
signal.throwIfAborted();
|
|
222
|
+
const binding = this.#binding(request.endpointId, request.cardDigest, request.authBindingId);
|
|
223
|
+
const response = await this.#request(binding.pollUrl, binding.authorization, request, signal);
|
|
224
|
+
if (!response.ok) throw new Error(`Workroom A2A poll HTTP ${response.status}`);
|
|
225
|
+
return await this.#json(response, 'poll snapshot') as unknown as RemoteCallbackPollSnapshot;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#binding(endpointId: string, cardDigest: string, authBindingId: string): CompiledRemoteBinding {
|
|
229
|
+
const binding = this.#bindings.get(endpointId);
|
|
230
|
+
if (!binding || !binding.enabled
|
|
231
|
+
|| binding.cardDigest !== cardDigest
|
|
232
|
+
|| binding.authBindingId !== authBindingId) {
|
|
233
|
+
throw new Error('Workroom A2A endpoint authority does not match the active transport generation');
|
|
234
|
+
}
|
|
235
|
+
return binding;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async #request(
|
|
239
|
+
url: string,
|
|
240
|
+
authorization: string,
|
|
241
|
+
body: unknown,
|
|
242
|
+
signal: AbortSignal,
|
|
243
|
+
): Promise<Response> {
|
|
244
|
+
signal.throwIfAborted();
|
|
245
|
+
const parsed = new URL(url);
|
|
246
|
+
const addresses = isIP(parsed.hostname)
|
|
247
|
+
? [{ address: parsed.hostname, family: isIP(parsed.hostname) as 4 | 6 }]
|
|
248
|
+
: await this.#network.resolve(parsed.hostname);
|
|
249
|
+
if (addresses.length === 0) {
|
|
250
|
+
throw new Error(`Workroom A2A DNS returned no address for ${parsed.hostname}`);
|
|
251
|
+
}
|
|
252
|
+
const loopbackDestination = isLoopbackHostname(parsed.hostname);
|
|
253
|
+
for (const entry of addresses) {
|
|
254
|
+
if ((entry.family !== 4 && entry.family !== 6) || isIP(entry.address) !== entry.family) {
|
|
255
|
+
throw new Error(`Workroom A2A DNS returned an invalid address for ${parsed.hostname}`);
|
|
256
|
+
}
|
|
257
|
+
if (isBlockedIpAddress(entry.address)
|
|
258
|
+
&& !(loopbackDestination && isLoopbackAddress(entry.address))) {
|
|
259
|
+
throw new Error(`Workroom A2A DNS target ${entry.address} is private or dangerous`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const selected = addresses[0]!;
|
|
263
|
+
return await this.#network.request({
|
|
264
|
+
url: parsed,
|
|
265
|
+
address: selected.address,
|
|
266
|
+
family: selected.family,
|
|
267
|
+
headers: Object.freeze({
|
|
268
|
+
authorization,
|
|
269
|
+
'content-type': 'application/json',
|
|
270
|
+
'x-zhin-workroom-extension': 'https://zhin.dev/extensions/workroom-executor/v1',
|
|
271
|
+
}),
|
|
272
|
+
body: JSON.stringify(body),
|
|
273
|
+
signal,
|
|
274
|
+
maxResponseBytes: this.#maxResponseBytes,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async #json(response: Response, label: string): Promise<Record<string, unknown>> {
|
|
279
|
+
const declared = response.headers.get('content-length');
|
|
280
|
+
if (declared !== null && Number(declared) > this.#maxResponseBytes) {
|
|
281
|
+
await response.body?.cancel();
|
|
282
|
+
throw new Error(`Workroom A2A ${label} exceeds ${this.#maxResponseBytes} bytes`);
|
|
283
|
+
}
|
|
284
|
+
if (!response.body) throw new Error(`Workroom A2A ${label} has no response body`);
|
|
285
|
+
const reader = response.body.getReader();
|
|
286
|
+
const chunks: Uint8Array[] = [];
|
|
287
|
+
let total = 0;
|
|
288
|
+
for (;;) {
|
|
289
|
+
const chunk = await reader.read();
|
|
290
|
+
if (chunk.done) break;
|
|
291
|
+
total += chunk.value.byteLength;
|
|
292
|
+
if (total > this.#maxResponseBytes) {
|
|
293
|
+
await reader.cancel();
|
|
294
|
+
throw new Error(`Workroom A2A ${label} exceeds ${this.#maxResponseBytes} bytes`);
|
|
295
|
+
}
|
|
296
|
+
chunks.push(chunk.value);
|
|
297
|
+
}
|
|
298
|
+
const bytes = new Uint8Array(total);
|
|
299
|
+
let offset = 0;
|
|
300
|
+
for (const chunk of chunks) {
|
|
301
|
+
bytes.set(chunk, offset);
|
|
302
|
+
offset += chunk.byteLength;
|
|
303
|
+
}
|
|
304
|
+
let value: unknown;
|
|
305
|
+
try {
|
|
306
|
+
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
throw new Error(`Workroom A2A ${label} is not valid JSON`, { cause: error });
|
|
309
|
+
}
|
|
310
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
311
|
+
throw new Error(`Workroom A2A ${label} must be an object`);
|
|
312
|
+
}
|
|
313
|
+
return value as Record<string, unknown>;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
class NodeWorkroomA2aPinnedNetwork implements WorkroomA2aPinnedNetworkPort {
|
|
318
|
+
async resolve(hostname: string): Promise<readonly WorkroomA2aResolvedAddress[]> {
|
|
319
|
+
const addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
320
|
+
return addresses.map(entry => Object.freeze({
|
|
321
|
+
address: entry.address,
|
|
322
|
+
family: entry.family as 4 | 6,
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
request(input: WorkroomA2aPinnedRequest): Promise<Response> {
|
|
327
|
+
const requester = input.url.protocol === 'https:' ? https : http;
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const request = requester.request({
|
|
330
|
+
protocol: input.url.protocol,
|
|
331
|
+
hostname: input.address,
|
|
332
|
+
family: input.family,
|
|
333
|
+
port: input.url.port || undefined,
|
|
334
|
+
path: `${input.url.pathname}${input.url.search}`,
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: {
|
|
337
|
+
...input.headers,
|
|
338
|
+
Host: input.url.host,
|
|
339
|
+
'Accept-Encoding': 'identity',
|
|
340
|
+
'Content-Length': String(Buffer.byteLength(input.body)),
|
|
341
|
+
},
|
|
342
|
+
signal: input.signal,
|
|
343
|
+
...(input.url.protocol === 'https:' && !isIP(input.url.hostname)
|
|
344
|
+
? { servername: input.url.hostname }
|
|
345
|
+
: {}),
|
|
346
|
+
}, response => {
|
|
347
|
+
const chunks: Buffer[] = [];
|
|
348
|
+
let size = 0;
|
|
349
|
+
response.on('data', (chunk: Buffer | string) => {
|
|
350
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
351
|
+
size += buffer.length;
|
|
352
|
+
if (size > input.maxResponseBytes) {
|
|
353
|
+
response.destroy(new Error(
|
|
354
|
+
`Workroom A2A response exceeds ${input.maxResponseBytes} bytes`,
|
|
355
|
+
));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
chunks.push(buffer);
|
|
359
|
+
});
|
|
360
|
+
response.once('error', reject);
|
|
361
|
+
response.once('end', () => resolve(new Response(Buffer.concat(chunks), {
|
|
362
|
+
status: response.statusCode ?? 500,
|
|
363
|
+
statusText: response.statusMessage,
|
|
364
|
+
headers: response.headers as HeadersInit,
|
|
365
|
+
})));
|
|
366
|
+
});
|
|
367
|
+
request.once('error', reject);
|
|
368
|
+
request.end(input.body);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function compileEndpointAuthority(
|
|
374
|
+
input: NonNullable<WorkroomA2aRemoteTransportBindingInput['authority']>,
|
|
375
|
+
callback: WorkroomA2aRegisteredAuthBindingSnapshot,
|
|
376
|
+
dispatchUrl: string,
|
|
377
|
+
pollUrl: string,
|
|
378
|
+
): WorkroomRemoteEndpointAuthority {
|
|
379
|
+
exactKeys(input, [
|
|
380
|
+
'workroomExtension', 'idempotentDispatch', 'typedCompletionEnvelope',
|
|
381
|
+
'workspaceProviders',
|
|
382
|
+
], 'remote endpoint authority');
|
|
383
|
+
if (input.workroomExtension !== WORKROOM_A2A_EXTENSION_URI_VALUE
|
|
384
|
+
|| input.idempotentDispatch !== true
|
|
385
|
+
|| input.typedCompletionEnvelope !== true) {
|
|
386
|
+
throw new Error('Workroom A2A remote endpoint authority lacks the v1 execution contract');
|
|
387
|
+
}
|
|
388
|
+
const expectedExtensionDigest = `sha256:${createHash('sha256')
|
|
389
|
+
.update(WORKROOM_A2A_EXTENSION_URI_VALUE).digest('hex')}`;
|
|
390
|
+
if (callback.extensionDigest !== expectedExtensionDigest) {
|
|
391
|
+
throw new Error('Workroom A2A remote endpoint extension digest drift');
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(input.workspaceProviders) || input.workspaceProviders.length === 0) {
|
|
394
|
+
throw new Error('Workroom A2A remote endpoint requires Workspace providers');
|
|
395
|
+
}
|
|
396
|
+
const workspaceProviders = [...new Set(input.workspaceProviders.map(provider =>
|
|
397
|
+
text(provider, 'workspaceProvider')))].sort((left, right) => left.localeCompare(right));
|
|
398
|
+
if (workspaceProviders.length !== input.workspaceProviders.length) {
|
|
399
|
+
throw new Error('Workroom A2A remote endpoint Workspace providers contain duplicates');
|
|
400
|
+
}
|
|
401
|
+
const transportProjection = {
|
|
402
|
+
version: 1,
|
|
403
|
+
generation: callback.generation,
|
|
404
|
+
endpointId: callback.endpointId,
|
|
405
|
+
cardDigest: callback.cardDigest,
|
|
406
|
+
authBindingId: callback.authBindingId,
|
|
407
|
+
extensionDigest: callback.extensionDigest,
|
|
408
|
+
credentialIdDigest: callback.credentialIdDigest,
|
|
409
|
+
dispatchUrl,
|
|
410
|
+
pollUrl,
|
|
411
|
+
};
|
|
412
|
+
return Object.freeze({
|
|
413
|
+
generation: callback.generation,
|
|
414
|
+
transportBindingDigest: `sha256:${createHash('sha256')
|
|
415
|
+
.update(JSON.stringify(transportProjection)).digest('hex')}`,
|
|
416
|
+
endpoint: Object.freeze({
|
|
417
|
+
id: callback.endpointId,
|
|
418
|
+
owner: callback.tenantId,
|
|
419
|
+
cardDigest: callback.cardDigest,
|
|
420
|
+
authBindingId: callback.authBindingId,
|
|
421
|
+
workroomExtension: WORKROOM_A2A_EXTENSION_URI_VALUE,
|
|
422
|
+
idempotentDispatch: true,
|
|
423
|
+
typedCompletionEnvelope: true,
|
|
424
|
+
workspaceProviders: Object.freeze(workspaceProviders),
|
|
425
|
+
}),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function resolveCredential(
|
|
430
|
+
reference: WorkroomA2aCredentialReference,
|
|
431
|
+
provider: WorkroomA2aSecureCredentialProvider | undefined,
|
|
432
|
+
): string {
|
|
433
|
+
if (!reference || typeof reference !== 'object' || Array.isArray(reference)) {
|
|
434
|
+
throw new Error('Workroom A2A remote credential reference must be an object');
|
|
435
|
+
}
|
|
436
|
+
if (reference.source === 'config') {
|
|
437
|
+
exactKeys(reference, ['source', 'value'], 'remote config credential');
|
|
438
|
+
return credential(reference.value);
|
|
439
|
+
}
|
|
440
|
+
if (reference.source === 'secure_provider') {
|
|
441
|
+
exactKeys(reference, ['source', 'secretRef'], 'remote secure credential');
|
|
442
|
+
const secretRef = text(reference.secretRef, 'secretRef');
|
|
443
|
+
if (!provider) throw new Error('Workroom A2A remote secure credential provider is required');
|
|
444
|
+
return credential(provider.resolve(secretRef));
|
|
445
|
+
}
|
|
446
|
+
throw new Error('Workroom A2A remote credential source is unsupported');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function canonicalUrl(value: unknown, field: string): string {
|
|
450
|
+
const raw = text(value, field);
|
|
451
|
+
let url: URL;
|
|
452
|
+
try {
|
|
453
|
+
url = new URL(raw);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw new Error(`Workroom A2A ${field} is not a valid URL`, { cause: error });
|
|
456
|
+
}
|
|
457
|
+
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1';
|
|
458
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
459
|
+
throw new Error(`Workroom A2A ${field} must use HTTPS or loopback HTTP`);
|
|
460
|
+
}
|
|
461
|
+
if (url.username || url.password || url.hash) {
|
|
462
|
+
throw new Error(`Workroom A2A ${field} must not embed credentials or fragments`);
|
|
463
|
+
}
|
|
464
|
+
return url.toString();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function trustedDestinationUrl(value: unknown, field: string, trustDomainValue: string): string {
|
|
468
|
+
const canonical = canonicalUrl(value, field);
|
|
469
|
+
const url = new URL(canonical);
|
|
470
|
+
const trustDomain = text(trustDomainValue, 'trustDomain').toLowerCase();
|
|
471
|
+
if (url.hostname.toLowerCase() !== trustDomain) {
|
|
472
|
+
throw new Error(`Workroom A2A ${field} host is outside the trusted destination`);
|
|
473
|
+
}
|
|
474
|
+
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
|
|
475
|
+
if (url.protocol === 'https:' && url.port && url.port !== '443') {
|
|
476
|
+
throw new Error(`Workroom A2A ${field} uses an unapproved HTTPS port`);
|
|
477
|
+
}
|
|
478
|
+
if (url.protocol === 'http:' && !loopback) {
|
|
479
|
+
throw new Error(`Workroom A2A ${field} uses non-loopback HTTP`);
|
|
480
|
+
}
|
|
481
|
+
if (isIP(url.hostname) && isBlockedIpAddress(url.hostname) && !loopback) {
|
|
482
|
+
throw new Error(`Workroom A2A ${field} host is private or dangerous`);
|
|
483
|
+
}
|
|
484
|
+
return canonical;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
488
|
+
return ['127.0.0.1', 'localhost', '::1'].includes(hostname.toLowerCase());
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isLoopbackAddress(address: string): boolean {
|
|
492
|
+
const normalized = address.toLowerCase();
|
|
493
|
+
return normalized === '::1' || normalized.startsWith('127.');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function isBlockedIpAddress(address: string): boolean {
|
|
497
|
+
const normalized = address.toLowerCase().replace(/^\[|\]$/gu, '');
|
|
498
|
+
if (isIP(normalized) === 4) {
|
|
499
|
+
const [a, b] = normalized.split('.').map(Number);
|
|
500
|
+
return a === 0
|
|
501
|
+
|| a === 10
|
|
502
|
+
|| a === 127
|
|
503
|
+
|| (a === 100 && b! >= 64 && b! <= 127)
|
|
504
|
+
|| (a === 169 && b === 254)
|
|
505
|
+
|| (a === 172 && b! >= 16 && b! <= 31)
|
|
506
|
+
|| (a === 192 && b === 0)
|
|
507
|
+
|| (a === 192 && b === 168)
|
|
508
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
509
|
+
|| a! >= 224;
|
|
510
|
+
}
|
|
511
|
+
if (isIP(normalized) === 6) {
|
|
512
|
+
if (normalized === '::' || normalized === '::1') return true;
|
|
513
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/iu.exec(normalized)?.[1];
|
|
514
|
+
if (mapped) return isBlockedIpAddress(mapped);
|
|
515
|
+
return /^(?:fc|fd)/iu.test(normalized)
|
|
516
|
+
|| /^fe[89ab]/iu.test(normalized)
|
|
517
|
+
|| /^ff/iu.test(normalized);
|
|
518
|
+
}
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function exactKeys(value: object, keys: readonly string[], label: string): void {
|
|
523
|
+
const unexpected = Object.keys(value).find(key => !keys.includes(key));
|
|
524
|
+
if (unexpected) throw new Error(`Workroom A2A ${label} contains unsupported field ${unexpected}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function text(value: unknown, field: string): string {
|
|
528
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`Workroom A2A ${field} is required`);
|
|
529
|
+
if (/\r|\n/u.test(value)) throw new Error(`Workroom A2A ${field} contains control characters`);
|
|
530
|
+
return value.trim();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function credential(value: unknown): string {
|
|
534
|
+
const result = text(value, 'credential');
|
|
535
|
+
if (result.length > 8_192) throw new Error('Workroom A2A credential is too large');
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function digest(value: unknown, field: string): string {
|
|
540
|
+
const result = text(value, field);
|
|
541
|
+
if (!/^sha256:[a-f0-9]{64}$/u.test(result)) throw new Error(`Workroom A2A ${field} is invalid`);
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function boolean(value: unknown, field: string): boolean {
|
|
546
|
+
if (typeof value !== 'boolean') throw new Error(`Workroom A2A ${field} must be boolean`);
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function positiveInteger(value: unknown, field: string): number {
|
|
551
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1) {
|
|
552
|
+
throw new Error(`Workroom A2A ${field} must be a positive safe integer`);
|
|
553
|
+
}
|
|
554
|
+
return Number(value);
|
|
555
|
+
}
|