@tomflow/proflow-agent-gateway 0.1.0

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.
@@ -0,0 +1,430 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { isIP } from "node:net";
4
+ import { z } from "zod";
5
+ const MAX_ACTION_CHARS = 100_000;
6
+ const MAX_INPUT_FILES = 10;
7
+ const MAX_FILE_BYTES = 10_000_000;
8
+ const RELAY_TTL_MS = 300_000;
9
+ export class AgentGatewayError extends Error {
10
+ code;
11
+ constructor(code, message = code) {
12
+ super(`${code}: ${message}`);
13
+ this.code = code;
14
+ }
15
+ }
16
+ const fileInputSchema = z
17
+ .object({
18
+ name: z.string().min(1),
19
+ id: z.string().min(1),
20
+ mime_type: z.string().min(1),
21
+ download_link: z.url(),
22
+ })
23
+ .strict();
24
+ const actionSchema = z
25
+ .object({
26
+ operationId: z.string().min(1),
27
+ body: z.record(z.string(), z.unknown()).default({}),
28
+ uncertain: z.boolean().optional(),
29
+ })
30
+ .strict();
31
+ const ownerFileBridgeArtifactSchema = z.object({
32
+ artifactRef: z.string().min(1),
33
+ name: z.string().min(1),
34
+ mimeType: z.string().min(1),
35
+ content: z.string().min(1),
36
+ });
37
+ const ownerFileBridgeOutputSchema = z.object({
38
+ fileArtifacts: z.array(ownerFileBridgeArtifactSchema).max(MAX_INPUT_FILES),
39
+ });
40
+ // Only this explicit allowed operation may produce the typed File Bridge
41
+ // descriptor. Any other owner output that happens to carry a same-named
42
+ // `fileArtifacts` key is treated as ordinary JSON and never reaches the
43
+ // GPT-facing `openaiFileResponse` serializer.
44
+ const FILE_BRIDGE_OUTPUT_OPERATIONS = new Set([
45
+ "getTaskDocument",
46
+ ]);
47
+ function safeFilename(name) {
48
+ return (name.length > 0 &&
49
+ name !== "." &&
50
+ name !== ".." &&
51
+ !/[\\/]/.test(name) &&
52
+ ![...name].some((character) => {
53
+ const code = character.codePointAt(0) ?? 0;
54
+ return code < 32 || code === 127;
55
+ }) &&
56
+ !name.includes(".."));
57
+ }
58
+ function privateIpv4(host) {
59
+ const parts = host.split(".").map(Number);
60
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part)))
61
+ return false;
62
+ return (parts[0] === 0 ||
63
+ parts[0] === 10 ||
64
+ parts[0] === 127 ||
65
+ (parts[0] === 100 && (parts[1] ?? 0) >= 64 && (parts[1] ?? 0) <= 127) ||
66
+ (parts[0] === 169 && parts[1] === 254) ||
67
+ (parts[0] === 172 && (parts[1] ?? 0) >= 16 && (parts[1] ?? 0) <= 31) ||
68
+ (parts[0] === 192 && parts[1] === 168) ||
69
+ (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) ||
70
+ (parts[0] ?? 0) >= 224);
71
+ }
72
+ export async function createAgentGateway(options) {
73
+ const now = options.now ?? Date.now;
74
+ const host = options.host ?? "127.0.0.1";
75
+ const port = options.port ?? 0;
76
+ let server;
77
+ let lifecycle = "STOPPED";
78
+ let inFlight = 0;
79
+ const relays = new Map();
80
+ const assertSafeRemoteUrl = (raw) => {
81
+ let url;
82
+ try {
83
+ url = new URL(raw);
84
+ }
85
+ catch {
86
+ throw new AgentGatewayError("OPENAI_FILE_INPUT_INVALID");
87
+ }
88
+ const hostname = url.hostname.toLowerCase();
89
+ if (url.protocol !== "https:" ||
90
+ hostname === "localhost" ||
91
+ hostname === "metadata.google.internal" ||
92
+ privateIpv4(hostname) ||
93
+ (isIP(hostname) === 6 &&
94
+ (hostname === "::1" ||
95
+ hostname.startsWith("fe80:") ||
96
+ hostname.startsWith("fc") ||
97
+ hostname.startsWith("fd"))))
98
+ throw new AgentGatewayError("OPENAI_FILE_INPUT_INVALID");
99
+ return url;
100
+ };
101
+ const normalizeFileInputs = (raw) => {
102
+ const list = z.array(fileInputSchema).parse(raw);
103
+ if (list.length > MAX_INPUT_FILES)
104
+ throw new AgentGatewayError("OPENAI_FILE_COUNT_EXCEEDED");
105
+ for (const item of list) {
106
+ if (!safeFilename(item.name))
107
+ throw new AgentGatewayError("OPENAI_FILE_INPUT_INVALID");
108
+ assertSafeRemoteUrl(item.download_link);
109
+ }
110
+ return list;
111
+ };
112
+ const createRelay = (artifact) => {
113
+ if (!safeFilename(artifact.name))
114
+ throw new AgentGatewayError("OPENAI_FILE_INPUT_INVALID");
115
+ if (/^(image|video)\//.test(artifact.mimeType))
116
+ throw new AgentGatewayError("OPENAI_FILE_RESPONSE_UNSUPPORTED_MEDIA");
117
+ if (artifact.bytes.length > MAX_FILE_BYTES)
118
+ throw new AgentGatewayError("OPENAI_FILE_RESPONSE_TOO_LARGE");
119
+ const token = randomBytes(32).toString("base64url");
120
+ relays.set(token, { ...artifact, expiresAt: now() + RELAY_TTL_MS });
121
+ const url = new URL(encodeURIComponent(token), options.relayBaseUrl);
122
+ return {
123
+ token,
124
+ url: url.href,
125
+ };
126
+ };
127
+ const readRelay = async (token, method, artifactRef) => {
128
+ const relay = relays.get(token);
129
+ if (!relay || relay.expiresAt < now())
130
+ throw new AgentGatewayError("OPENAI_FILE_RELAY_EXPIRED");
131
+ if (method !== "GET" ||
132
+ (artifactRef !== undefined && relay.artifactRef !== artifactRef))
133
+ throw new AgentGatewayError("OPENAI_FILE_RELAY_SCOPE_INVALID");
134
+ return {
135
+ body: relay.bytes,
136
+ headers: {
137
+ "content-type": relay.mimeType,
138
+ "content-disposition": `attachment; filename="${relay.name}"`,
139
+ },
140
+ };
141
+ };
142
+ const serializeFileResponse = (raw) => {
143
+ const artifacts = z
144
+ .array(z.object({
145
+ artifactRef: z.string().min(1),
146
+ name: z.string().min(1),
147
+ mimeType: z.string().min(1),
148
+ bytes: z.instanceof(Buffer),
149
+ }))
150
+ .parse(raw);
151
+ if (artifacts.length > MAX_INPUT_FILES)
152
+ throw new AgentGatewayError("OPENAI_FILE_COUNT_EXCEEDED");
153
+ for (const artifact of artifacts) {
154
+ if (!safeFilename(artifact.name))
155
+ throw new AgentGatewayError("OPENAI_FILE_INPUT_INVALID");
156
+ if (/^(image|video)\//.test(artifact.mimeType))
157
+ throw new AgentGatewayError("OPENAI_FILE_RESPONSE_UNSUPPORTED_MEDIA");
158
+ if (artifact.bytes.length > MAX_FILE_BYTES)
159
+ throw new AgentGatewayError("OPENAI_FILE_RESPONSE_TOO_LARGE");
160
+ }
161
+ const items = artifacts.map((artifact) => ({
162
+ kind: "inline",
163
+ name: artifact.name,
164
+ mime_type: artifact.mimeType,
165
+ content: artifact.bytes.toString("base64"),
166
+ }));
167
+ for (let index = artifacts.length - 1; index >= 0; index -= 1) {
168
+ const candidate = { openaiFileResponse: items };
169
+ if (JSON.stringify(candidate).length < MAX_ACTION_CHARS)
170
+ return candidate;
171
+ const artifact = artifacts[index];
172
+ if (!artifact)
173
+ continue;
174
+ const relay = createRelay(artifact);
175
+ items[index] = {
176
+ kind: "url",
177
+ name: artifact.name,
178
+ mime_type: artifact.mimeType,
179
+ download_link: relay.url,
180
+ };
181
+ }
182
+ const candidate = { openaiFileResponse: items };
183
+ if (JSON.stringify(candidate).length >= MAX_ACTION_CHARS)
184
+ throw new AgentGatewayError("OPENAI_ACTION_RESPONSE_BUDGET_EXCEEDED");
185
+ return candidate;
186
+ };
187
+ const ownerFileBridgeOutput = (operationId, output) => {
188
+ if (!FILE_BRIDGE_OUTPUT_OPERATIONS.has(operationId))
189
+ return undefined;
190
+ if (typeof output !== "object" || output === null || Array.isArray(output))
191
+ return undefined;
192
+ if (!("fileArtifacts" in output))
193
+ return undefined;
194
+ const parsed = ownerFileBridgeOutputSchema.parse(output);
195
+ return parsed.fileArtifacts.map((item) => ({
196
+ artifactRef: item.artifactRef,
197
+ name: item.name,
198
+ mimeType: item.mimeType,
199
+ bytes: Buffer.from(item.content, "utf8"),
200
+ }));
201
+ };
202
+ const readiness = async () => {
203
+ const checks = await options.owners.readiness();
204
+ // Intrinsic Gateway capabilities are verified here rather than trusting the
205
+ // owner to report them: ingress is the real accepting/bound socket state, and
206
+ // relay requires a valid HTTPS base URL that an external client can fetch.
207
+ let relayReady = false;
208
+ try {
209
+ relayReady = new URL(options.relayBaseUrl).protocol === "https:";
210
+ }
211
+ catch {
212
+ relayReady = false;
213
+ }
214
+ const merged = {
215
+ ...checks,
216
+ ingress: lifecycle === "RUNNING" && server !== undefined,
217
+ relay: relayReady && checks.relay !== false,
218
+ };
219
+ return {
220
+ status: Object.values(merged).every(Boolean)
221
+ ? "READY"
222
+ : "NOT_READY",
223
+ checks: merged,
224
+ };
225
+ };
226
+ const handler = async (request, response) => {
227
+ inFlight += 1;
228
+ try {
229
+ const url = new URL(request.url ?? "/", `http://${host}`);
230
+ if (request.method === "GET" && url.pathname === "/health") {
231
+ response.setHeader("content-type", "application/json; charset=utf-8");
232
+ response.setHeader("cache-control", "no-store");
233
+ response.end(JSON.stringify({ status: "UP" }));
234
+ return;
235
+ }
236
+ if (request.method === "GET" && url.pathname === "/ready") {
237
+ const state = await readiness();
238
+ response.statusCode = state.status === "READY" ? 200 : 503;
239
+ response.setHeader("content-type", "application/json; charset=utf-8");
240
+ response.setHeader("cache-control", "no-store");
241
+ response.end(JSON.stringify(state));
242
+ return;
243
+ }
244
+ if (url.pathname.startsWith("/relay/")) {
245
+ if (request.method !== "GET" || url.search !== "") {
246
+ response.statusCode = 404;
247
+ response.end();
248
+ return;
249
+ }
250
+ try {
251
+ const relay = await readRelay(decodeURIComponent(url.pathname.slice("/relay/".length)), request.method);
252
+ response.setHeader("cache-control", "private, no-store");
253
+ response.setHeader("x-content-type-options", "nosniff");
254
+ for (const [name, value] of Object.entries(relay.headers))
255
+ response.setHeader(name, value);
256
+ response.end(relay.body);
257
+ }
258
+ catch {
259
+ response.statusCode = 404;
260
+ response.end();
261
+ }
262
+ return;
263
+ }
264
+ response.setHeader("content-type", "application/json; charset=utf-8");
265
+ const authorization = request.headers.authorization;
266
+ if (!authorization?.startsWith("Bearer ")) {
267
+ response.statusCode = 401;
268
+ response.end(JSON.stringify({ error: "AUTHENTICATION_FAILED" }));
269
+ return;
270
+ }
271
+ let authenticatedRoleRef;
272
+ try {
273
+ authenticatedRoleRef = await options.owners.authenticateBearer(authorization.slice("Bearer ".length));
274
+ }
275
+ catch {
276
+ response.statusCode = 401;
277
+ response.end(JSON.stringify({ error: "AUTHENTICATION_FAILED" }));
278
+ return;
279
+ }
280
+ let action;
281
+ if (request.method === "GET" && url.pathname.startsWith("/actions/")) {
282
+ action = {
283
+ operationId: decodeURIComponent(url.pathname.slice("/actions/".length)),
284
+ body: Object.fromEntries(url.searchParams),
285
+ };
286
+ }
287
+ else if (request.method === "POST" &&
288
+ (url.pathname === "/actions" || url.pathname.startsWith("/actions/"))) {
289
+ const chunks = [];
290
+ let chars = 0;
291
+ for await (const chunk of request) {
292
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
293
+ chars += buffer.toString("utf8").length;
294
+ if (chars >= MAX_ACTION_CHARS) {
295
+ response.statusCode = 413;
296
+ response.end(JSON.stringify({
297
+ error: "OPENAI_ACTION_REQUEST_BUDGET_EXCEEDED",
298
+ }));
299
+ return;
300
+ }
301
+ chunks.push(buffer);
302
+ }
303
+ const parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf8"));
304
+ action = actionSchema.parse(url.pathname === "/actions"
305
+ ? parsedBody
306
+ : {
307
+ operationId: decodeURIComponent(url.pathname.slice("/actions/".length)),
308
+ body: parsedBody,
309
+ });
310
+ }
311
+ else {
312
+ response.statusCode = 404;
313
+ response.end(JSON.stringify({ error: "NOT_FOUND" }));
314
+ return;
315
+ }
316
+ const { roleRef: _untrustedRoleRef, openaiFileIdRefs: rawFileInputs, ...canonicalBody } = action.body;
317
+ const fileMaterializationInputs = rawFileInputs === undefined
318
+ ? undefined
319
+ : normalizeFileInputs(rawFileInputs).map((file) => ({
320
+ name: file.name,
321
+ provenanceRef: file.id,
322
+ declaredMimeType: file.mime_type,
323
+ sourceUrl: file.download_link,
324
+ }));
325
+ if (fileMaterializationInputs !== undefined) {
326
+ if (action.operationId !== "putTaskDocument")
327
+ throw new AgentGatewayError("OPENAI_FILE_INPUT_UNSUPPORTED_OPERATION");
328
+ if (fileMaterializationInputs.length !== 1)
329
+ throw new AgentGatewayError("OPENAI_FILE_COUNT_EXCEEDED");
330
+ if (typeof canonicalBody.content === "string" &&
331
+ canonicalBody.content.length > 0)
332
+ throw new AgentGatewayError("OPENAI_FILE_INPUT_CONFLICT");
333
+ }
334
+ const actionSignal = AbortSignal.timeout(options.actionTimeoutMs ?? 45_000);
335
+ const operation = action.uncertain && options.owners.lookupResult
336
+ ? options.owners.lookupResult(action.operationId, authenticatedRoleRef, canonicalBody)
337
+ : options.owners.route(action.operationId, authenticatedRoleRef, canonicalBody, {
338
+ signal: actionSignal,
339
+ ...(fileMaterializationInputs === undefined
340
+ ? {}
341
+ : { fileMaterializationInputs }),
342
+ });
343
+ const output = await Promise.race([
344
+ operation,
345
+ new Promise((_resolve, reject) => actionSignal.addEventListener("abort", () => reject(Object.assign(new AgentGatewayError("OPENAI_ACTION_TIMEOUT"), {
346
+ httpStatus: 504,
347
+ })), { once: true })),
348
+ ]);
349
+ const fileArtifacts = ownerFileBridgeOutput(action.operationId, output);
350
+ if (fileArtifacts !== undefined) {
351
+ response.end(JSON.stringify(serializeFileResponse(fileArtifacts)));
352
+ return;
353
+ }
354
+ const serialized = JSON.stringify(output);
355
+ if (serialized.length >= MAX_ACTION_CHARS) {
356
+ response.statusCode = 500;
357
+ response.end(JSON.stringify({ error: "OPENAI_ACTION_RESPONSE_BUDGET_EXCEEDED" }));
358
+ return;
359
+ }
360
+ response.end(serialized);
361
+ }
362
+ catch (error) {
363
+ if (error instanceof z.ZodError || error instanceof SyntaxError) {
364
+ response.statusCode = 400;
365
+ response.end(JSON.stringify({ error: "INVALID_REQUEST" }));
366
+ return;
367
+ }
368
+ const requestedStatus = error && typeof error === "object" && "httpStatus" in error
369
+ ? Number(error.httpStatus)
370
+ : 500;
371
+ response.statusCode =
372
+ Number.isInteger(requestedStatus) &&
373
+ requestedStatus >= 400 &&
374
+ requestedStatus <= 599
375
+ ? requestedStatus
376
+ : 500;
377
+ response.end(JSON.stringify({
378
+ error: error instanceof AgentGatewayError ? error.code : "GATEWAY_FAILURE",
379
+ }));
380
+ }
381
+ finally {
382
+ inFlight -= 1;
383
+ }
384
+ };
385
+ const start = async () => {
386
+ if (server)
387
+ throw new AgentGatewayError("SERVICE_ALREADY_RUNNING");
388
+ server = createServer((request, response) => void handler(request, response));
389
+ await new Promise((resolve, reject) => {
390
+ server?.once("error", reject);
391
+ server?.listen(port, host, resolve);
392
+ });
393
+ const address = server.address();
394
+ if (!address || typeof address === "string")
395
+ throw new AgentGatewayError("SERVICE_START_FAILED");
396
+ lifecycle = "RUNNING";
397
+ return { host, port: address.port };
398
+ };
399
+ const stop = async () => {
400
+ const active = server;
401
+ server = undefined;
402
+ if (!active)
403
+ return;
404
+ lifecycle = "DRAINING";
405
+ await new Promise((resolve, reject) => active.close((error) => (error ? reject(error) : resolve())));
406
+ lifecycle = "STOPPED";
407
+ };
408
+ const restart = async () => {
409
+ await stop();
410
+ return start();
411
+ };
412
+ return Object.freeze({
413
+ start,
414
+ stop,
415
+ restart,
416
+ readiness,
417
+ status: () => ({
418
+ process: lifecycle,
419
+ liveness: lifecycle === "STOPPED" ? "DOWN" : "UP",
420
+ accepting: lifecycle === "RUNNING",
421
+ inFlight,
422
+ }),
423
+ assertSafeRemoteUrl,
424
+ normalizeFileInputs,
425
+ createRelay,
426
+ readRelay,
427
+ serializeFileResponse,
428
+ businessPersistence: Object.freeze([]),
429
+ });
430
+ }
@@ -0,0 +1,70 @@
1
+ export type AgentGatewayProcessConfig = {
2
+ host: string;
3
+ port: number;
4
+ publicBaseUrl: string;
5
+ downstreamBaseUrl: string;
6
+ credentialFile: string;
7
+ downstreamCredentialFile?: string;
8
+ };
9
+ export declare function parseAgentGatewayProcessConfig(value: unknown): AgentGatewayProcessConfig;
10
+ export declare function loadAgentGatewayProcessConfig(path: string): Promise<AgentGatewayProcessConfig>;
11
+ export declare function createAgentGatewayProcess(input: {
12
+ config: AgentGatewayProcessConfig;
13
+ fetch?: typeof globalThis.fetch;
14
+ log?: (entry: Record<string, unknown>) => void;
15
+ }): Promise<Readonly<{
16
+ readiness: () => Promise<{
17
+ status: "NOT_READY" | "READY";
18
+ checks: {
19
+ ingress: boolean;
20
+ relay: boolean;
21
+ };
22
+ }>;
23
+ status: () => {
24
+ process: "DRAINING" | "RUNNING" | "STOPPED";
25
+ liveness: "DOWN" | "UP";
26
+ accepting: boolean;
27
+ inFlight: number;
28
+ };
29
+ assertSafeRemoteUrl: (raw: string) => URL;
30
+ normalizeFileInputs: (raw: unknown) => {
31
+ name: string;
32
+ id: string;
33
+ mime_type: string;
34
+ download_link: string;
35
+ }[];
36
+ createRelay: (artifact: {
37
+ artifactRef: string;
38
+ name: string;
39
+ mimeType: string;
40
+ bytes: Buffer;
41
+ }) => {
42
+ token: string;
43
+ url: string;
44
+ };
45
+ readRelay: (token: string, method: string, artifactRef?: string) => Promise<{
46
+ body: Buffer<ArrayBufferLike>;
47
+ headers: {
48
+ "content-type": string;
49
+ "content-disposition": string;
50
+ };
51
+ }>;
52
+ serializeFileResponse: (raw: {
53
+ artifactRef: string;
54
+ name: string;
55
+ mimeType: string;
56
+ bytes: Buffer;
57
+ }[]) => {
58
+ openaiFileResponse: Record<string, string>[];
59
+ };
60
+ businessPersistence: readonly never[];
61
+ start: () => Promise<{
62
+ host: string;
63
+ port: number;
64
+ }>;
65
+ stop: () => Promise<void>;
66
+ restart(): Promise<{
67
+ host: string;
68
+ port: number;
69
+ }>;
70
+ }>>;
@@ -0,0 +1,193 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { createAgentGateway } from "./index.js";
5
+ function record(value, name) {
6
+ if (typeof value !== "object" || value === null || Array.isArray(value))
7
+ throw new TypeError(`${name} must be an object`);
8
+ return value;
9
+ }
10
+ function text(value, name) {
11
+ if (typeof value !== "string" || value.length === 0)
12
+ throw new TypeError(`${name} must be a non-empty string`);
13
+ return value;
14
+ }
15
+ export function parseAgentGatewayProcessConfig(value) {
16
+ const input = record(value, "agent-gateway config");
17
+ const publicBaseUrl = new URL(text(input.publicBaseUrl, "publicBaseUrl"));
18
+ if (publicBaseUrl.protocol !== "https:")
19
+ throw new TypeError("publicBaseUrl must be HTTPS");
20
+ const downstreamBaseUrl = new URL(text(input.downstreamBaseUrl, "downstreamBaseUrl"));
21
+ if (downstreamBaseUrl.protocol !== "http:" ||
22
+ !["localhost", "127.0.0.1", "::1"].includes(downstreamBaseUrl.hostname))
23
+ throw new TypeError("downstreamBaseUrl must be loopback HTTP");
24
+ const port = input.port === undefined ? 0 : Number(input.port);
25
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
26
+ throw new TypeError("port must be an integer from 0 through 65535");
27
+ return {
28
+ host: input.host === undefined ? "127.0.0.1" : text(input.host, "host"),
29
+ port,
30
+ publicBaseUrl: publicBaseUrl.href.replace(/\/$/, ""),
31
+ downstreamBaseUrl: downstreamBaseUrl.href.replace(/\/$/, ""),
32
+ credentialFile: resolve(text(input.credentialFile, "credentialFile")),
33
+ ...(input.downstreamCredentialFile === undefined
34
+ ? {}
35
+ : {
36
+ downstreamCredentialFile: resolve(text(input.downstreamCredentialFile, "downstreamCredentialFile")),
37
+ }),
38
+ };
39
+ }
40
+ export async function loadAgentGatewayProcessConfig(path) {
41
+ return parseAgentGatewayProcessConfig(JSON.parse(await readFile(resolve(path), "utf8")));
42
+ }
43
+ function sameSecret(left, right) {
44
+ const a = createHash("sha256").update(left).digest();
45
+ const b = createHash("sha256").update(right).digest();
46
+ return timingSafeEqual(a, b);
47
+ }
48
+ function parseCredentialStore(value) {
49
+ const store = record(value, "credential store");
50
+ const credentials = {};
51
+ for (const [roleRef, credential] of Object.entries(store)) {
52
+ if (roleRef.length === 0 ||
53
+ typeof credential !== "string" ||
54
+ credential.length < 16)
55
+ throw new TypeError("credential store contains an invalid role credential");
56
+ credentials[roleRef] = credential;
57
+ }
58
+ return credentials;
59
+ }
60
+ async function readCurrentCredentialStore(file) {
61
+ return parseCredentialStore(JSON.parse(await readFile(file, "utf8")));
62
+ }
63
+ async function readDownstreamCredential(file) {
64
+ const info = await stat(file);
65
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0)
66
+ throw new Error("DOWNSTREAM_TRANSPORT_CREDENTIAL_PERMISSIONS_INVALID");
67
+ const credential = (await readFile(file, "utf8")).trim();
68
+ if (credential.length < 32)
69
+ throw new Error("DOWNSTREAM_TRANSPORT_CREDENTIAL_INVALID");
70
+ return credential;
71
+ }
72
+ export async function createAgentGatewayProcess(input) {
73
+ const fetchImplementation = input.fetch ?? globalThis.fetch;
74
+ const credentialFile = input.config.credentialFile;
75
+ // Fail-fast at startup so a malformed configured credential store is rejected
76
+ // before the process advertises readiness. Authentication re-reads the current
77
+ // store on every attempt below, so a rotated key takes effect without a restart
78
+ // and a malformed/half-written store fails closed instead of serving a stale
79
+ // snapshot.
80
+ await readCurrentCredentialStore(credentialFile);
81
+ if (input.config.downstreamCredentialFile)
82
+ await readDownstreamCredential(input.config.downstreamCredentialFile);
83
+ const credentialAuthority = async () => {
84
+ try {
85
+ return (Object.keys(await readCurrentCredentialStore(credentialFile)).length > 0);
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ };
91
+ const downstream = async (path, body, signal) => {
92
+ const downstreamCredential = input.config.downstreamCredentialFile
93
+ ? await readDownstreamCredential(input.config.downstreamCredentialFile)
94
+ : undefined;
95
+ const response = await fetchImplementation(`${input.config.downstreamBaseUrl}${path}`, {
96
+ method: body === undefined ? "GET" : "POST",
97
+ headers: {
98
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
99
+ ...(downstreamCredential
100
+ ? { authorization: `Bearer ${downstreamCredential}` }
101
+ : {}),
102
+ },
103
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
104
+ ...(signal ? { signal } : {}),
105
+ });
106
+ if (!response.ok)
107
+ throw Object.assign(new Error("DOWNSTREAM_UNAVAILABLE"), {
108
+ httpStatus: response.status,
109
+ });
110
+ return response.json();
111
+ };
112
+ const gateway = await createAgentGateway({
113
+ host: input.config.host,
114
+ port: input.config.port,
115
+ relayBaseUrl: `${input.config.publicBaseUrl}/relay/`,
116
+ owners: {
117
+ async authenticateBearer(credential) {
118
+ const credentials = await readCurrentCredentialStore(credentialFile);
119
+ for (const [roleRef, stored] of Object.entries(credentials))
120
+ if (sameSecret(credential, String(stored)))
121
+ return roleRef;
122
+ throw new Error("AUTHENTICATION_FAILED");
123
+ },
124
+ route(operationId, authenticatedRoleRef, value, context) {
125
+ return downstream(`/actions/${encodeURIComponent(operationId)}`, {
126
+ authenticatedRoleRef,
127
+ input: value,
128
+ ...(context?.fileMaterializationInputs === undefined
129
+ ? {}
130
+ : {
131
+ fileMaterializationInputs: context.fileMaterializationInputs,
132
+ }),
133
+ }, context?.signal);
134
+ },
135
+ lookupResult(operationId, authenticatedRoleRef, value) {
136
+ return downstream(`/actions/${encodeURIComponent(operationId)}/result`, {
137
+ authenticatedRoleRef,
138
+ input: value,
139
+ });
140
+ },
141
+ async readiness() {
142
+ try {
143
+ const downstreamCredential = input.config.downstreamCredentialFile
144
+ ? await readDownstreamCredential(input.config.downstreamCredentialFile)
145
+ : undefined;
146
+ const response = await fetchImplementation(`${input.config.downstreamBaseUrl}/ready`, {
147
+ headers: downstreamCredential
148
+ ? { authorization: `Bearer ${downstreamCredential}` }
149
+ : {},
150
+ signal: AbortSignal.timeout(2_000),
151
+ });
152
+ return {
153
+ credentialStore: await credentialAuthority(),
154
+ downstream: response.ok,
155
+ };
156
+ }
157
+ catch {
158
+ return {
159
+ credentialStore: await credentialAuthority(),
160
+ downstream: false,
161
+ };
162
+ }
163
+ },
164
+ },
165
+ });
166
+ const start = async () => {
167
+ const address = await gateway.start();
168
+ input.log?.({
169
+ timestamp: new Date().toISOString(),
170
+ component: "agent-gateway-process",
171
+ event: "SERVICE_STARTED",
172
+ ...address,
173
+ });
174
+ return address;
175
+ };
176
+ const stop = async () => {
177
+ await gateway.stop();
178
+ input.log?.({
179
+ timestamp: new Date().toISOString(),
180
+ component: "agent-gateway-process",
181
+ event: "SERVICE_STOPPED",
182
+ });
183
+ };
184
+ return Object.freeze({
185
+ ...gateway,
186
+ start,
187
+ stop,
188
+ async restart() {
189
+ await stop();
190
+ return start();
191
+ },
192
+ });
193
+ }