@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.
- package/README.md +5 -0
- package/conformance.json +1 -0
- package/dist/deployment/adapter.d.ts +452 -0
- package/dist/deployment/adapter.js +240 -0
- package/dist/deployment/descriptor.d.ts +77 -0
- package/dist/deployment/descriptor.js +88 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +51 -0
- package/dist/src/index.d.ts +99 -0
- package/dist/src/index.js +430 -0
- package/dist/src/process.d.ts +70 -0
- package/dist/src/process.js +193 -0
- package/package.json +61 -0
- package/proflow.module.json +106 -0
|
@@ -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
|
+
}
|