@zhin.js/a2a 3.0.13 → 3.0.14
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 +50 -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,279 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export type WorkroomA2aCredentialReference =
|
|
4
|
+
| Readonly<{ source: 'config'; value: string }>
|
|
5
|
+
| Readonly<{ source: 'secure_provider'; secretRef: string }>;
|
|
6
|
+
|
|
7
|
+
export interface WorkroomA2aAuthBindingInput {
|
|
8
|
+
readonly endpointId: string;
|
|
9
|
+
readonly tenantId: string;
|
|
10
|
+
readonly cardDigest: string;
|
|
11
|
+
readonly authBindingId: string;
|
|
12
|
+
readonly trustDomain: string;
|
|
13
|
+
readonly extensionDigest: string;
|
|
14
|
+
readonly credentialId: string;
|
|
15
|
+
readonly credential: WorkroomA2aCredentialReference;
|
|
16
|
+
readonly enabled: boolean;
|
|
17
|
+
readonly expiresAt?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkroomA2aSecureCredentialProvider {
|
|
21
|
+
resolve(secretRef: string): string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorkroomA2aAuthRegistryOptions {
|
|
25
|
+
readonly generation: number;
|
|
26
|
+
readonly bindings: readonly WorkroomA2aAuthBindingInput[];
|
|
27
|
+
readonly secureCredentialProvider?: WorkroomA2aSecureCredentialProvider;
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WorkroomA2aEndpointAuthoritySnapshot {
|
|
32
|
+
readonly version: 1;
|
|
33
|
+
readonly endpointId: string;
|
|
34
|
+
readonly tenantId: string;
|
|
35
|
+
readonly cardDigest: string;
|
|
36
|
+
readonly authBindingId: string;
|
|
37
|
+
readonly trustDomain: string;
|
|
38
|
+
readonly generation: number;
|
|
39
|
+
readonly extensionDigest: string;
|
|
40
|
+
readonly credentialIdDigest: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WorkroomA2aRegisteredAuthBindingSnapshot
|
|
44
|
+
extends WorkroomA2aEndpointAuthoritySnapshot {
|
|
45
|
+
readonly enabled: boolean;
|
|
46
|
+
readonly expiresAt?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkroomA2aAuthRegistrySnapshot {
|
|
50
|
+
readonly version: 1;
|
|
51
|
+
readonly generation: number;
|
|
52
|
+
readonly bindings: readonly WorkroomA2aRegisteredAuthBindingSnapshot[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CompiledBinding {
|
|
56
|
+
readonly credentialDigest: Buffer;
|
|
57
|
+
/** Trusted transport-only secret; never exposed through snapshots or serialization. */
|
|
58
|
+
readonly credential: string;
|
|
59
|
+
readonly authority: WorkroomA2aEndpointAuthoritySnapshot;
|
|
60
|
+
readonly enabled: boolean;
|
|
61
|
+
readonly expiresAt?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class WorkroomA2aAuthenticationError extends Error {
|
|
65
|
+
constructor() {
|
|
66
|
+
super('Workroom A2A credential is unknown or inactive');
|
|
67
|
+
this.name = 'WorkroomA2aAuthenticationError';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Generation-owned immutable credential-to-endpoint authority registry. */
|
|
72
|
+
export class WorkroomA2aAuthRegistry {
|
|
73
|
+
readonly #bindings: readonly CompiledBinding[];
|
|
74
|
+
readonly #now: () => number;
|
|
75
|
+
readonly snapshot: WorkroomA2aAuthRegistrySnapshot;
|
|
76
|
+
|
|
77
|
+
constructor(options: WorkroomA2aAuthRegistryOptions) {
|
|
78
|
+
validateOptions(options);
|
|
79
|
+
this.#now = options.now ?? Date.now;
|
|
80
|
+
const endpointIds = new Set<string>();
|
|
81
|
+
const authBindingIds = new Set<string>();
|
|
82
|
+
const credentialIds = new Set<string>();
|
|
83
|
+
const credentialDigests = new Set<string>();
|
|
84
|
+
const bindings: CompiledBinding[] = [];
|
|
85
|
+
for (const input of options.bindings) {
|
|
86
|
+
validateBinding(input);
|
|
87
|
+
if (endpointIds.has(input.endpointId)) {
|
|
88
|
+
throw new Error(`Workroom A2A endpoint binding drift: ${input.endpointId}`);
|
|
89
|
+
}
|
|
90
|
+
if (credentialIds.has(input.credentialId)) {
|
|
91
|
+
throw new Error(`Workroom A2A duplicate credentialId: ${input.credentialId}`);
|
|
92
|
+
}
|
|
93
|
+
if (authBindingIds.has(input.authBindingId)) {
|
|
94
|
+
throw new Error(`Workroom A2A authBindingId drift: ${input.authBindingId}`);
|
|
95
|
+
}
|
|
96
|
+
endpointIds.add(input.endpointId);
|
|
97
|
+
authBindingIds.add(input.authBindingId);
|
|
98
|
+
credentialIds.add(input.credentialId);
|
|
99
|
+
const credential = resolveCredential(input.credential, options.secureCredentialProvider);
|
|
100
|
+
const credentialDigest = hash(credential);
|
|
101
|
+
const digestKey = credentialDigest.toString('hex');
|
|
102
|
+
if (credentialDigests.has(digestKey)) {
|
|
103
|
+
throw new Error('Workroom A2A duplicate credential value');
|
|
104
|
+
}
|
|
105
|
+
credentialDigests.add(digestKey);
|
|
106
|
+
const authority = deepFreeze({
|
|
107
|
+
version: 1 as const,
|
|
108
|
+
endpointId: input.endpointId,
|
|
109
|
+
tenantId: input.tenantId,
|
|
110
|
+
cardDigest: input.cardDigest,
|
|
111
|
+
authBindingId: input.authBindingId,
|
|
112
|
+
trustDomain: input.trustDomain,
|
|
113
|
+
generation: options.generation,
|
|
114
|
+
extensionDigest: input.extensionDigest,
|
|
115
|
+
credentialIdDigest: digestString(input.credentialId),
|
|
116
|
+
});
|
|
117
|
+
bindings.push({
|
|
118
|
+
credentialDigest,
|
|
119
|
+
credential,
|
|
120
|
+
authority,
|
|
121
|
+
enabled: input.enabled,
|
|
122
|
+
...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
this.#bindings = Object.freeze(bindings);
|
|
126
|
+
this.snapshot = deepFreeze({
|
|
127
|
+
version: 1 as const,
|
|
128
|
+
generation: options.generation,
|
|
129
|
+
bindings: bindings.map(binding => ({
|
|
130
|
+
...binding.authority,
|
|
131
|
+
enabled: binding.enabled,
|
|
132
|
+
...(binding.expiresAt === undefined ? {} : { expiresAt: binding.expiresAt }),
|
|
133
|
+
})),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Only the transport credential participates; callback claims are not an input. */
|
|
138
|
+
authenticate(requestCredential: string): WorkroomA2aEndpointAuthoritySnapshot {
|
|
139
|
+
if (typeof requestCredential !== 'string' || requestCredential.length === 0) {
|
|
140
|
+
throw new WorkroomA2aAuthenticationError();
|
|
141
|
+
}
|
|
142
|
+
const candidate = hash(requestCredential);
|
|
143
|
+
let matched: CompiledBinding | undefined;
|
|
144
|
+
for (const binding of this.#bindings) {
|
|
145
|
+
if (timingSafeEqual(candidate, binding.credentialDigest)) matched = binding;
|
|
146
|
+
}
|
|
147
|
+
const now = this.#now();
|
|
148
|
+
if (!Number.isFinite(now)) throw new Error('Workroom A2A authentication clock must be finite');
|
|
149
|
+
if (!matched || !matched.enabled
|
|
150
|
+
|| (matched.expiresAt !== undefined && now >= matched.expiresAt)) {
|
|
151
|
+
throw new WorkroomA2aAuthenticationError();
|
|
152
|
+
}
|
|
153
|
+
return matched.authority;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Issues the callback credential only to the trusted outbound transport.
|
|
158
|
+
* The endpoint id is resolved against this exact generation and inactive
|
|
159
|
+
* credentials fail closed just like inbound authentication.
|
|
160
|
+
*/
|
|
161
|
+
callbackAuthorization(endpointId: string): string {
|
|
162
|
+
text(endpointId, 'endpointId');
|
|
163
|
+
const matched = this.#bindings.find(binding => binding.authority.endpointId === endpointId);
|
|
164
|
+
const now = this.#now();
|
|
165
|
+
if (!Number.isFinite(now)) throw new Error('Workroom A2A authentication clock must be finite');
|
|
166
|
+
if (!matched || !matched.enabled
|
|
167
|
+
|| (matched.expiresAt !== undefined && now >= matched.expiresAt)) {
|
|
168
|
+
throw new WorkroomA2aAuthenticationError();
|
|
169
|
+
}
|
|
170
|
+
return `Bearer ${matched.credential}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateOptions(options: WorkroomA2aAuthRegistryOptions): void {
|
|
175
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
176
|
+
throw new Error('Workroom A2A auth registry options must be an object');
|
|
177
|
+
}
|
|
178
|
+
assertExactKeys(options, ['generation', 'bindings', 'secureCredentialProvider', 'now'], 'options');
|
|
179
|
+
positiveInteger(options.generation, 'generation');
|
|
180
|
+
if (!Array.isArray(options.bindings)) throw new Error('Workroom A2A bindings must be an array');
|
|
181
|
+
if (options.now !== undefined && typeof options.now !== 'function') {
|
|
182
|
+
throw new Error('Workroom A2A now must be a function');
|
|
183
|
+
}
|
|
184
|
+
if (options.secureCredentialProvider !== undefined
|
|
185
|
+
&& (!options.secureCredentialProvider
|
|
186
|
+
|| typeof options.secureCredentialProvider !== 'object'
|
|
187
|
+
|| typeof options.secureCredentialProvider.resolve !== 'function')) {
|
|
188
|
+
throw new Error('Workroom A2A secure credential provider resolve must be a function');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateBinding(input: WorkroomA2aAuthBindingInput): void {
|
|
193
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
194
|
+
throw new Error('Workroom A2A auth binding must be an object');
|
|
195
|
+
}
|
|
196
|
+
assertExactKeys(input, [
|
|
197
|
+
'endpointId', 'tenantId', 'cardDigest', 'authBindingId', 'trustDomain',
|
|
198
|
+
'extensionDigest', 'credentialId', 'credential', 'enabled', 'expiresAt',
|
|
199
|
+
], 'binding');
|
|
200
|
+
for (const [label, value] of Object.entries({
|
|
201
|
+
endpointId: input.endpointId,
|
|
202
|
+
tenantId: input.tenantId,
|
|
203
|
+
authBindingId: input.authBindingId,
|
|
204
|
+
trustDomain: input.trustDomain,
|
|
205
|
+
credentialId: input.credentialId,
|
|
206
|
+
})) text(value, label);
|
|
207
|
+
canonicalDigest(input.cardDigest, 'cardDigest');
|
|
208
|
+
canonicalDigest(input.extensionDigest, 'extensionDigest');
|
|
209
|
+
if (typeof input.enabled !== 'boolean') throw new Error('Workroom A2A enabled must be boolean');
|
|
210
|
+
if (input.expiresAt !== undefined && !Number.isFinite(input.expiresAt)) {
|
|
211
|
+
throw new Error('Workroom A2A expiresAt must be finite');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveCredential(
|
|
216
|
+
reference: WorkroomA2aCredentialReference,
|
|
217
|
+
provider: WorkroomA2aSecureCredentialProvider | undefined,
|
|
218
|
+
): string {
|
|
219
|
+
if (!reference || typeof reference !== 'object' || Array.isArray(reference)) {
|
|
220
|
+
throw new Error('Workroom A2A credential reference must be an object');
|
|
221
|
+
}
|
|
222
|
+
if (reference.source === 'config') {
|
|
223
|
+
assertExactKeys(reference, ['source', 'value'], 'config credential');
|
|
224
|
+
return credential(reference.value);
|
|
225
|
+
}
|
|
226
|
+
if (reference.source === 'secure_provider') {
|
|
227
|
+
assertExactKeys(reference, ['source', 'secretRef'], 'secure credential');
|
|
228
|
+
text(reference.secretRef, 'credential secretRef');
|
|
229
|
+
if (!provider) throw new Error('Workroom A2A secure credential provider is required');
|
|
230
|
+
return credential(provider.resolve(reference.secretRef));
|
|
231
|
+
}
|
|
232
|
+
throw new Error('Workroom A2A credential source is unsupported');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function credential(value: unknown): string {
|
|
236
|
+
text(value, 'credential value');
|
|
237
|
+
const result = value;
|
|
238
|
+
if (result.length > 8_192 || /\s/u.test(result)) {
|
|
239
|
+
throw new Error('Workroom A2A credential must be a bounded token without whitespace');
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function hash(value: string): Buffer {
|
|
245
|
+
return createHash('sha256').update(value).digest();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function digestString(value: string): string {
|
|
249
|
+
return `sha256:${hash(value).toString('hex')}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function assertExactKeys(value: object, allowed: readonly string[], label: string): void {
|
|
253
|
+
const unexpected = Object.keys(value).find(key => !allowed.includes(key));
|
|
254
|
+
if (unexpected) throw new Error(`Workroom A2A ${label} contains forbidden field ${unexpected}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function text(value: unknown, label: string): asserts value is string {
|
|
258
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
259
|
+
throw new Error(`Workroom A2A ${label} must be non-empty text`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function positiveInteger(value: unknown, label: string): asserts value is number {
|
|
264
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
265
|
+
throw new Error(`Workroom A2A ${label} must be a positive safe integer`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function canonicalDigest(value: unknown, label: string): asserts value is string {
|
|
270
|
+
if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(value)) {
|
|
271
|
+
throw new Error(`Workroom A2A ${label} must be a canonical sha256 digest`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function deepFreeze<T>(value: T): T {
|
|
276
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
277
|
+
for (const item of Object.values(value as Record<string, unknown>)) deepFreeze(item);
|
|
278
|
+
return Object.freeze(value);
|
|
279
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { HttpRouteHost } from '@zhin.js/host-http-contract';
|
|
3
|
+
import type {
|
|
4
|
+
RemoteCallbackApplicationOutcome,
|
|
5
|
+
RemoteExecutionLinkRecord,
|
|
6
|
+
WorkroomCallbackAuthRegistry,
|
|
7
|
+
WorkroomRemoteCallbackGatewayResult,
|
|
8
|
+
WorkroomRemoteCallbackRequest,
|
|
9
|
+
} from '@zhin.js/agent';
|
|
10
|
+
import { WorkroomA2aAuthenticationError } from './workroom-auth-registry.js';
|
|
11
|
+
|
|
12
|
+
export interface RuntimeWorkroomCallbackDependencies {
|
|
13
|
+
readonly authRegistry: Pick<WorkroomCallbackAuthRegistry, 'authenticate'>;
|
|
14
|
+
readonly gateway: Readonly<{
|
|
15
|
+
handle(
|
|
16
|
+
request: WorkroomRemoteCallbackRequest,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
): Promise<WorkroomRemoteCallbackGatewayResult>;
|
|
19
|
+
}>;
|
|
20
|
+
readonly linkRegistry: Readonly<{
|
|
21
|
+
listRegistered(): Promise<readonly Pick<RemoteExecutionLinkRecord, 'id'>[]>;
|
|
22
|
+
}>;
|
|
23
|
+
readonly application: Readonly<{
|
|
24
|
+
runOnce(linkId: string, signal: AbortSignal): Promise<RemoteCallbackApplicationOutcome>;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InstallRuntimeWorkroomCallbacksOptions {
|
|
29
|
+
readonly http: HttpRouteHost;
|
|
30
|
+
/** Exact route, intentionally separate from `/a2a/{agent}/*`. */
|
|
31
|
+
readonly path?: string;
|
|
32
|
+
readonly ordinaryA2aBasePath?: string;
|
|
33
|
+
readonly dependencies: RuntimeWorkroomCallbackDependencies;
|
|
34
|
+
readonly maxBodyBytes: number;
|
|
35
|
+
readonly signal?: AbortSignal;
|
|
36
|
+
/** Root handoff uses this so database-backed Workroom state activates first. */
|
|
37
|
+
readonly deferRecovery?: boolean;
|
|
38
|
+
readonly onRecoveryError?: (linkId: string, error: unknown) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RuntimeWorkroomCallbackRecoverySummary {
|
|
42
|
+
readonly registered: number;
|
|
43
|
+
readonly recovered: number;
|
|
44
|
+
readonly failed: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RuntimeWorkroomCallbackInstallation {
|
|
48
|
+
readonly recovery: RuntimeWorkroomCallbackRecoverySummary | undefined;
|
|
49
|
+
recover(signal?: AbortSignal): Promise<RuntimeWorkroomCallbackRecoverySummary>;
|
|
50
|
+
dispose(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Installs the authority-isolated Workroom callback ingress. This route never
|
|
55
|
+
* invokes the ordinary A2A Agent executor; accepted observations can reach
|
|
56
|
+
* Workroom state only through the injected Callback Application.
|
|
57
|
+
*/
|
|
58
|
+
export async function installRuntimeWorkroomCallbacks(
|
|
59
|
+
options: InstallRuntimeWorkroomCallbacksOptions,
|
|
60
|
+
): Promise<RuntimeWorkroomCallbackInstallation> {
|
|
61
|
+
const path = normalizeExactPath(options.path ?? '/workroom-a2a/callback');
|
|
62
|
+
const ordinaryA2aBasePath = normalizeBasePath(options.ordinaryA2aBasePath ?? '/a2a');
|
|
63
|
+
if (ordinaryA2aBasePath === '/'
|
|
64
|
+
|| path === ordinaryA2aBasePath
|
|
65
|
+
|| path.startsWith(`${ordinaryA2aBasePath}/`)) {
|
|
66
|
+
throw new Error('Workroom callback path must be outside ordinary A2A inbound');
|
|
67
|
+
}
|
|
68
|
+
const maxBodyBytes = positiveInteger(options.maxBodyBytes, 'maxBodyBytes');
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const signal = options.signal
|
|
71
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
72
|
+
: controller.signal;
|
|
73
|
+
let recovery = options.deferRecovery === true
|
|
74
|
+
? undefined
|
|
75
|
+
: await recoverRegisteredLinks(options.dependencies, signal, options.onRecoveryError);
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
|
|
78
|
+
const unregister = options.http.route('POST', path, async (request, response) => {
|
|
79
|
+
const credential = bearerCredential(request);
|
|
80
|
+
if (!credential) {
|
|
81
|
+
writeJson(response, 401, { error: 'Unauthorized' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Authenticate before consuming attacker-controlled request bytes. The
|
|
85
|
+
// Gateway authenticates again while deriving the immutable authority.
|
|
86
|
+
try {
|
|
87
|
+
options.dependencies.authRegistry.authenticate(credential);
|
|
88
|
+
} catch {
|
|
89
|
+
writeJson(response, 401, { error: 'Unauthorized' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const requestController = new AbortController();
|
|
94
|
+
const abortRequest = () => requestController.abort(new Error('Callback request aborted'));
|
|
95
|
+
request.once('aborted', abortRequest);
|
|
96
|
+
const requestSignal = AbortSignal.any([signal, requestController.signal]);
|
|
97
|
+
try {
|
|
98
|
+
const body = await readRawBody(request, maxBodyBytes);
|
|
99
|
+
const result = await options.dependencies.gateway.handle({ credential, body }, requestSignal);
|
|
100
|
+
writeJson(response, 202, {
|
|
101
|
+
accepted: true,
|
|
102
|
+
duplicate: result.duplicate,
|
|
103
|
+
applicationStatus: result.application.status,
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof WorkroomA2aAuthenticationError) {
|
|
107
|
+
writeJson(response, 401, { error: 'Unauthorized' });
|
|
108
|
+
} else if (error instanceof RuntimeWorkroomCallbackBodyError) {
|
|
109
|
+
writeJson(
|
|
110
|
+
response,
|
|
111
|
+
error.statusCode,
|
|
112
|
+
{ error: error.message },
|
|
113
|
+
error.closeConnection ? () => request.destroy() : undefined,
|
|
114
|
+
);
|
|
115
|
+
} else if (!response.headersSent && signal.aborted && !requestController.signal.aborted) {
|
|
116
|
+
writeJson(response, 503, { error: 'Callback Host unavailable' });
|
|
117
|
+
} else if (!response.headersSent && !requestSignal.aborted) {
|
|
118
|
+
writeJson(response, 400, { error: 'Callback rejected' });
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
request.off('aborted', abortRequest);
|
|
122
|
+
}
|
|
123
|
+
}, {
|
|
124
|
+
summary: 'Authenticated Workroom remote execution callback',
|
|
125
|
+
tags: ['workroom', 'a2a-callback'],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let disposed = false;
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
get recovery() { return recovery; },
|
|
131
|
+
async recover(recoverySignal?: AbortSignal) {
|
|
132
|
+
const activeSignal = recoverySignal
|
|
133
|
+
? AbortSignal.any([signal, recoverySignal])
|
|
134
|
+
: signal;
|
|
135
|
+
recovery = await recoverRegisteredLinks(
|
|
136
|
+
options.dependencies,
|
|
137
|
+
activeSignal,
|
|
138
|
+
options.onRecoveryError,
|
|
139
|
+
);
|
|
140
|
+
return recovery;
|
|
141
|
+
},
|
|
142
|
+
dispose() {
|
|
143
|
+
if (disposed) return;
|
|
144
|
+
disposed = true;
|
|
145
|
+
controller.abort(new Error('Workroom callback Host retired'));
|
|
146
|
+
unregister();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function recoverRegisteredLinks(
|
|
152
|
+
dependencies: RuntimeWorkroomCallbackDependencies,
|
|
153
|
+
signal: AbortSignal,
|
|
154
|
+
onRecoveryError: ((linkId: string, error: unknown) => void) | undefined,
|
|
155
|
+
): Promise<RuntimeWorkroomCallbackRecoverySummary> {
|
|
156
|
+
signal.throwIfAborted();
|
|
157
|
+
const records = [...await dependencies.linkRegistry.listRegistered()]
|
|
158
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
159
|
+
let recovered = 0;
|
|
160
|
+
let failed = 0;
|
|
161
|
+
for (const record of records) {
|
|
162
|
+
signal.throwIfAborted();
|
|
163
|
+
try {
|
|
164
|
+
await dependencies.application.runOnce(record.id, signal);
|
|
165
|
+
recovered += 1;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
failed += 1;
|
|
168
|
+
onRecoveryError?.(record.id, error);
|
|
169
|
+
}
|
|
170
|
+
signal.throwIfAborted();
|
|
171
|
+
}
|
|
172
|
+
signal.throwIfAborted();
|
|
173
|
+
return Object.freeze({ registered: records.length, recovered, failed });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
class RuntimeWorkroomCallbackBodyError extends Error {
|
|
177
|
+
constructor(
|
|
178
|
+
message: string,
|
|
179
|
+
readonly statusCode: 400 | 413,
|
|
180
|
+
readonly closeConnection = false,
|
|
181
|
+
) {
|
|
182
|
+
super(message);
|
|
183
|
+
this.name = 'RuntimeWorkroomCallbackBodyError';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readRawBody(request: IncomingMessage, limit: number): Promise<Uint8Array> {
|
|
188
|
+
const declaredLength = declaredContentLength(request);
|
|
189
|
+
if (declaredLength !== undefined && declaredLength > limit) {
|
|
190
|
+
request.pause();
|
|
191
|
+
throw new RuntimeWorkroomCallbackBodyError(
|
|
192
|
+
`Callback body exceeds ${limit} bytes`,
|
|
193
|
+
413,
|
|
194
|
+
true,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const chunks = await new Promise<Buffer[]>((resolve, reject) => {
|
|
198
|
+
const buffered: Buffer[] = [];
|
|
199
|
+
let size = 0;
|
|
200
|
+
const cleanup = () => {
|
|
201
|
+
request.off('data', onData);
|
|
202
|
+
request.off('end', onEnd);
|
|
203
|
+
request.off('error', onError);
|
|
204
|
+
request.off('aborted', onAborted);
|
|
205
|
+
};
|
|
206
|
+
const onData = (chunk: Buffer | string) => {
|
|
207
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
208
|
+
size += buffer.length;
|
|
209
|
+
if (size > limit) {
|
|
210
|
+
request.pause();
|
|
211
|
+
cleanup();
|
|
212
|
+
reject(new RuntimeWorkroomCallbackBodyError(
|
|
213
|
+
`Callback body exceeds ${limit} bytes`,
|
|
214
|
+
413,
|
|
215
|
+
true,
|
|
216
|
+
));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
buffered.push(buffer);
|
|
220
|
+
};
|
|
221
|
+
const onEnd = () => {
|
|
222
|
+
cleanup();
|
|
223
|
+
resolve(buffered);
|
|
224
|
+
};
|
|
225
|
+
const onError = (error: Error) => {
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(error);
|
|
228
|
+
};
|
|
229
|
+
const onAborted = () => {
|
|
230
|
+
cleanup();
|
|
231
|
+
reject(new Error('Callback request aborted'));
|
|
232
|
+
};
|
|
233
|
+
request.on('data', onData);
|
|
234
|
+
request.once('end', onEnd);
|
|
235
|
+
request.once('error', onError);
|
|
236
|
+
request.once('aborted', onAborted);
|
|
237
|
+
});
|
|
238
|
+
if (chunks.length === 0) {
|
|
239
|
+
throw new RuntimeWorkroomCallbackBodyError('Callback body is required', 400);
|
|
240
|
+
}
|
|
241
|
+
return Buffer.concat(chunks);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function declaredContentLength(request: IncomingMessage): number | undefined {
|
|
245
|
+
const value = request.headers['content-length'];
|
|
246
|
+
if (typeof value !== 'string' || !/^\d+$/u.test(value)) return undefined;
|
|
247
|
+
const parsed = Number(value);
|
|
248
|
+
return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function bearerCredential(request: IncomingMessage): string | undefined {
|
|
252
|
+
const value = request.headers.authorization;
|
|
253
|
+
if (typeof value !== 'string') return undefined;
|
|
254
|
+
const match = /^Bearer ([^\s]+)$/u.exec(value);
|
|
255
|
+
return match?.[1];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizeExactPath(value: string): string {
|
|
259
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
260
|
+
throw new Error('Workroom callback path is required');
|
|
261
|
+
}
|
|
262
|
+
const leading = value.startsWith('/') ? value : `/${value}`;
|
|
263
|
+
let end = leading.length;
|
|
264
|
+
while (end > 1 && leading.charCodeAt(end - 1) === 47) end -= 1;
|
|
265
|
+
const normalized = leading.slice(0, end);
|
|
266
|
+
if (normalized.includes('*')) throw new Error('Workroom callback path must be exact');
|
|
267
|
+
return normalized;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function normalizeBasePath(value: string): string {
|
|
271
|
+
return normalizeExactPath(value);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function positiveInteger(value: unknown, field: string): number {
|
|
275
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1) {
|
|
276
|
+
throw new Error(`Workroom callback ${field} must be a positive safe integer`);
|
|
277
|
+
}
|
|
278
|
+
return Number(value);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function writeJson(
|
|
282
|
+
response: ServerResponse,
|
|
283
|
+
status: number,
|
|
284
|
+
value: unknown,
|
|
285
|
+
afterSend?: () => void,
|
|
286
|
+
): void {
|
|
287
|
+
const body = JSON.stringify(value);
|
|
288
|
+
response.writeHead(status, {
|
|
289
|
+
'content-type': 'application/json; charset=utf-8',
|
|
290
|
+
'content-length': String(Buffer.byteLength(body)),
|
|
291
|
+
...(afterSend ? { connection: 'close' } : {}),
|
|
292
|
+
});
|
|
293
|
+
response.end(body, afterSend);
|
|
294
|
+
}
|