@mono-agent/operator-adapter 0.19.0 → 0.20.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 +78 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tui/cron.d.ts +50 -0
- package/dist/tui/cron.d.ts.map +1 -0
- package/dist/tui/cron.js +13 -0
- package/dist/tui/cron.js.map +1 -0
- package/dist/tui/errors.d.ts +1 -1
- package/dist/tui/errors.d.ts.map +1 -1
- package/dist/tui/errors.js.map +1 -1
- package/dist/tui/index.d.ts +3 -0
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +1 -0
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/server.d.ts +8 -1
- package/dist/tui/server.d.ts.map +1 -1
- package/dist/tui/server.js +713 -9
- package/dist/tui/server.js.map +1 -1
- package/package.json +2 -2
package/dist/tui/server.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { isAbsolute } from "node:path";
|
|
4
|
-
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, BoundedHttpResponseWriter, agentAttachmentKindFromMimeType, closeServerBounded, createChannelUserCancelReason, decodeAgentAttachmentText, isAgentResponseCancelledError, serializeAgentStreamFrame, } from "@mono-agent/agent-contracts";
|
|
5
|
-
import { assertSafeBind, bearerTokensEqual, hostForUrl, isLoopbackHost, listen, normalizeOptionalString, readAuthorizationBearer, } from "@mono-agent/agent-contracts";
|
|
4
|
+
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, MCP_APP_RESOURCE_MIME_TYPE, MCP_APP_SUPPORTED_VERSIONS, MAX_AGENT_REPLY_PARTS, BoundedHttpResponseWriter, agentAttachmentKindFromMimeType, closeServerBounded, createChannelUserCancelReason, decodeAgentAttachmentText, isAgentResponseCancelledError, parseProcessJobProjection, parseProcessJobProjections, serializeAgentStreamFrame, } from "@mono-agent/agent-contracts";
|
|
5
|
+
import { assertSafeBind, bearerTokensEqual, hostForUrl, isLoopbackHost, listen, normalizeOptionalString, parseCronOperatorOverview, parseCronOperatorRunDetail, parseCronOperatorRunPage, readAuthorizationBearer, } from "@mono-agent/agent-contracts";
|
|
6
6
|
import express, {} from "express";
|
|
7
7
|
import { DEFAULT_BASE_PATH, DEFAULT_HOST, DEFAULT_PORT, MAX_FRAME_BYTES, TUI_WIRE_SCHEMA } from "./constants.js";
|
|
8
|
+
import { CronOperatorError, MAX_CRON_OPERATOR_RESPONSE_BYTES, MAX_CRON_OPERATOR_RUN_PAGE, } from "./cron.js";
|
|
8
9
|
import { TuiAdapterError } from "./errors.js";
|
|
9
10
|
const MAX_TURN_BODY_BYTES = 96 * 1024 * 1024;
|
|
10
11
|
const MAX_VERBATIM_BODY_BYTES = 2 * 1024 * 1024;
|
|
11
12
|
const MAX_VERBATIM_TEXT_CHARACTERS = 200_000;
|
|
12
13
|
const MAX_VERBATIM_TEXT_BYTES = 1024 * 1024;
|
|
13
14
|
const MAX_LIVE_INPUT_BODY_BYTES = 32 * 1024;
|
|
15
|
+
const MAX_PROCESS_JOBS_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
14
16
|
const MAX_WEB_ATTACHMENTS = 10;
|
|
15
17
|
const MAX_WEB_ATTACHMENT_BYTES = 64 * 1024 * 1024;
|
|
16
18
|
const MAX_REQUEST_TOOL_ENVIRONMENT_KEYS = 32;
|
|
17
19
|
const MAX_REQUEST_TOOL_ENVIRONMENT_VALUE_BYTES = 16 * 1024;
|
|
18
20
|
const MAX_REQUEST_TOOL_ENVIRONMENT_TOTAL_BYTES = 64 * 1024;
|
|
19
21
|
const MAX_REQUEST_TOOL_ENVIRONMENT_PATHS = 4;
|
|
22
|
+
const MAX_CRON_ACTION_BODY_BYTES = 32 * 1024;
|
|
23
|
+
const MAX_REPLY_ARTIFACT_ID_BYTES = 128;
|
|
24
|
+
const MAX_REPLY_ARTIFACT_CONVERSATION_BYTES = 4 * 1024;
|
|
25
|
+
const MAX_MCP_APP_IDENTITY_BYTES = 4 * 1024;
|
|
26
|
+
const MAX_MCP_APP_REQUEST_BYTES = 64 * 1024;
|
|
27
|
+
const REPLY_ARTIFACT_DRAIN_TIMEOUT_MS = 30_000;
|
|
20
28
|
const ALLOWED_ATTACHMENT_MIME_TYPES = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST.map((mimeType) => mimeType.toLowerCase()));
|
|
21
29
|
export async function startTuiAdapter(options) {
|
|
22
30
|
if (typeof options.responder?.respond !== "function") {
|
|
@@ -26,6 +34,10 @@ export async function startTuiAdapter(options) {
|
|
|
26
34
|
const port = options.port ?? DEFAULT_PORT;
|
|
27
35
|
const basePath = normalizeBasePath(options.basePath ?? DEFAULT_BASE_PATH);
|
|
28
36
|
const apiKey = normalizeOptionalString(options.apiKey);
|
|
37
|
+
const processJobsBearer = normalizeOptionalString(options.processJobsBearer);
|
|
38
|
+
if ((options.processJobs === undefined) !== (processJobsBearer === undefined)) {
|
|
39
|
+
throw new TuiAdapterError("invalid_config", "processJobs and processJobsBearer must be configured together.");
|
|
40
|
+
}
|
|
29
41
|
if (options.requestToolEnvironment !== undefined && !isLoopbackHost(host)) {
|
|
30
42
|
throw new TuiAdapterError("unsafe_host", "Request tool environment requires a loopback-only TUI adapter bind.", { host });
|
|
31
43
|
}
|
|
@@ -40,21 +52,76 @@ export async function startTuiAdapter(options) {
|
|
|
40
52
|
const cancelPath = `${basePath}/v1/conversations/:conversationId/cancel`;
|
|
41
53
|
const verbatimPath = `${basePath}/v1/conversations/:conversationId/verbatim`;
|
|
42
54
|
const liveInputPath = `${basePath}/v1/conversations/:conversationId/live-input`;
|
|
55
|
+
const replyArtifactPath = `${basePath}/v1/conversations/:conversationId/reply-artifacts/:artifactId`;
|
|
56
|
+
const mcpAppPath = `${basePath}/v1/conversations/:conversationId/mcp-apps/:invocationId`;
|
|
57
|
+
const mcpAppRequestPath = `${mcpAppPath}/requests`;
|
|
43
58
|
const askPath = `${basePath}/v1/conversations/:conversationId/ask`;
|
|
59
|
+
const interactionPath = `${basePath}/v1/interactions/:interactionId`;
|
|
60
|
+
const cronOverviewPath = `${basePath}/v1/cron`;
|
|
61
|
+
const cronRunsPath = `${basePath}/v1/cron/jobs/:jobId/runs`;
|
|
62
|
+
const cronRunDetailPath = `${basePath}/v1/cron/jobs/:jobId/runs/:runId`;
|
|
63
|
+
const cronConfigViewPath = `${basePath}/v1/cron/config-view`;
|
|
64
|
+
const cronRunNowPath = `${basePath}/v1/cron/jobs/:jobId/run`;
|
|
65
|
+
const cronEnabledPath = `${basePath}/v1/cron/jobs/:jobId/effective-enabled`;
|
|
66
|
+
const jobsPath = `${basePath}/v1/jobs`;
|
|
67
|
+
const jobPath = `${basePath}/v1/jobs/:jobId`;
|
|
68
|
+
const jobCancelPath = `${basePath}/v1/jobs/:jobId/cancel`;
|
|
44
69
|
app.get(infoPath, (req, res) => {
|
|
45
70
|
if (!authorize(req, res, apiKey)) {
|
|
46
71
|
return;
|
|
47
72
|
}
|
|
48
|
-
|
|
49
|
-
.
|
|
73
|
+
const cronInfo = options.cron === undefined
|
|
74
|
+
? Promise.resolve({ kind: "absent" })
|
|
75
|
+
: Promise.resolve()
|
|
76
|
+
.then(async () => await options.cron.overview())
|
|
77
|
+
.then((overview) => ({ kind: "available", overview }))
|
|
78
|
+
.catch((error) => {
|
|
79
|
+
// Cron is an additive capability, never the agent-liveness probe. A
|
|
80
|
+
// stopped registry or failed control store must not turn /v1/info into
|
|
81
|
+
// a 500 that makes the whole agent appear unreachable.
|
|
82
|
+
options.logger?.error?.("Cron operator overview failed during TUI info.", {
|
|
83
|
+
error: errorToMessage(error),
|
|
84
|
+
});
|
|
85
|
+
return { kind: "degraded" };
|
|
86
|
+
});
|
|
87
|
+
void Promise.all([resolveInfo(options.info), cronInfo])
|
|
88
|
+
.then(([info, cronState]) => {
|
|
50
89
|
res.status(200).json({
|
|
51
90
|
schema: TUI_WIRE_SCHEMA,
|
|
52
91
|
pid: process.pid,
|
|
53
92
|
capabilities: {
|
|
54
93
|
attachments: true,
|
|
94
|
+
...(typeof options.responder.openReplyArtifact === "function"
|
|
95
|
+
? { replyAttachments: { version: 1, maxBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES } }
|
|
96
|
+
: {}),
|
|
97
|
+
...(typeof options.responder.loadMcpApp === "function"
|
|
98
|
+
&& typeof options.responder.requestMcpApp === "function"
|
|
99
|
+
? {
|
|
100
|
+
mcpApps: {
|
|
101
|
+
bridgeVersion: 1,
|
|
102
|
+
versions: MCP_APP_SUPPORTED_VERSIONS,
|
|
103
|
+
mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE],
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
55
107
|
...(typeof options.responder.offerLiveInput === "function" ? { liveInput: true } : {}),
|
|
56
108
|
...(typeof options.responder.deliverVerbatim === "function" ? { historyAppend: true } : {}),
|
|
57
109
|
...(options.interaction === undefined ? {} : { askUser: true }),
|
|
110
|
+
...(typeof options.interaction?.getAsk === "function" ? { askById: true } : {}),
|
|
111
|
+
...(cronState.kind === "absent"
|
|
112
|
+
? {}
|
|
113
|
+
: cronState.kind === "degraded"
|
|
114
|
+
? { cron: { status: "degraded", read: false, actions: false } }
|
|
115
|
+
: {
|
|
116
|
+
cron: {
|
|
117
|
+
status: cronState.overview.degradedReason === undefined ? "ready" : "degraded",
|
|
118
|
+
read: true,
|
|
119
|
+
actions: cronState.overview.degradedReason === undefined
|
|
120
|
+
&& apiKey !== undefined
|
|
121
|
+
&& cronState.overview.actionsEnabled === true,
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
...(options.processJobs === undefined || processJobsBearer === undefined ? {} : { jobs: true }),
|
|
58
125
|
...(options.requestToolEnvironment === undefined ? {} : { toolEnvironment: true }),
|
|
59
126
|
},
|
|
60
127
|
...(info?.label === undefined ? {} : { label: info.label }),
|
|
@@ -72,6 +139,69 @@ export async function startTuiAdapter(options) {
|
|
|
72
139
|
sendJsonError(res, 500, error);
|
|
73
140
|
});
|
|
74
141
|
});
|
|
142
|
+
app.get(jobsPath, (req, res, next) => {
|
|
143
|
+
if (!authorize(req, res, processJobsBearer))
|
|
144
|
+
return;
|
|
145
|
+
if (options.processJobs === undefined || processJobsBearer === undefined) {
|
|
146
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Process jobs are unavailable."));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
void options.processJobs.list()
|
|
150
|
+
.then((jobs) => sendBoundedJobs(res, jobs))
|
|
151
|
+
.catch(next);
|
|
152
|
+
});
|
|
153
|
+
app.get(jobPath, (req, res, next) => {
|
|
154
|
+
if (!authorize(req, res, processJobsBearer))
|
|
155
|
+
return;
|
|
156
|
+
if (options.processJobs === undefined || processJobsBearer === undefined) {
|
|
157
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Process jobs are unavailable."));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const jobId = normalizeOptionalString(typeof req.params.jobId === "string" ? req.params.jobId : undefined);
|
|
161
|
+
if (jobId === undefined || jobId.length > 256) {
|
|
162
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A bounded jobId is required."));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
void options.processJobs.get(jobId)
|
|
166
|
+
.then((job) => {
|
|
167
|
+
if (job === undefined) {
|
|
168
|
+
res.status(404).json({ error: { code: "process_job_not_found", message: "Process job was not found." } });
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
sendBoundedJob(res, job);
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.catch(next);
|
|
175
|
+
});
|
|
176
|
+
app.post(jobCancelPath, (req, res, next) => {
|
|
177
|
+
if (!authorize(req, res, processJobsBearer))
|
|
178
|
+
return;
|
|
179
|
+
if (options.processJobs === undefined || processJobsBearer === undefined) {
|
|
180
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Process jobs are unavailable."));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const jobId = normalizeOptionalString(typeof req.params.jobId === "string" ? req.params.jobId : undefined);
|
|
184
|
+
if (jobId === undefined || jobId.length > 256) {
|
|
185
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A bounded jobId is required."));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
void options.processJobs.cancel(jobId)
|
|
189
|
+
.then((job) => sendBoundedJob(res, job))
|
|
190
|
+
.catch((error) => {
|
|
191
|
+
const code = typeof error === "object" && error !== null
|
|
192
|
+
? error.code
|
|
193
|
+
: undefined;
|
|
194
|
+
if (code === "process_job_not_found") {
|
|
195
|
+
res.status(404).json({ error: { code, message: errorToMessage(error) } });
|
|
196
|
+
}
|
|
197
|
+
else if (code === "process_job_conflict") {
|
|
198
|
+
res.status(409).json({ error: { code, message: errorToMessage(error) } });
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
next(error);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
75
205
|
// Keep the enlarged parser scoped to turn submission. 64 MiB of decoded
|
|
76
206
|
// files expands to about 85.4 MiB in base64, while info/cancel stay bodyless.
|
|
77
207
|
app.post(turnsPath, express.json({ limit: MAX_TURN_BODY_BYTES }), (req, res) => {
|
|
@@ -103,6 +233,101 @@ export async function startTuiAdapter(options) {
|
|
|
103
233
|
options.interaction?.cancelAsks(conversationId);
|
|
104
234
|
res.status(202).json({ cancelled: conversationId });
|
|
105
235
|
});
|
|
236
|
+
app.get(replyArtifactPath, (req, res, next) => {
|
|
237
|
+
if (!authorize(req, res, apiKey))
|
|
238
|
+
return;
|
|
239
|
+
if (typeof options.responder.openReplyArtifact !== "function") {
|
|
240
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "Reply artifacts are unavailable."));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const conversationId = normalizeOptionalString(typeof req.params.conversationId === "string" ? req.params.conversationId : undefined);
|
|
244
|
+
const artifactId = normalizeOptionalString(typeof req.params.artifactId === "string" ? req.params.artifactId : undefined);
|
|
245
|
+
const expectedIntegrityId = normalizeOptionalString(req.header("x-mono-agent-integrity-id"));
|
|
246
|
+
if (conversationId === undefined
|
|
247
|
+
|| artifactId === undefined
|
|
248
|
+
|| Buffer.byteLength(conversationId, "utf8") > MAX_REPLY_ARTIFACT_CONVERSATION_BYTES
|
|
249
|
+
|| Buffer.byteLength(artifactId, "utf8") > MAX_REPLY_ARTIFACT_ID_BYTES) {
|
|
250
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A valid conversation and artifact id are required."));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
void options.responder.openReplyArtifact({
|
|
254
|
+
conversationId,
|
|
255
|
+
reference: { scheme: "mono-agent-artifact", id: artifactId },
|
|
256
|
+
...(expectedIntegrityId === undefined ? {} : { expectedIntegrityId }),
|
|
257
|
+
}).then(async (opened) => {
|
|
258
|
+
setReplyArtifactHeaders(res, opened.attachment);
|
|
259
|
+
let streamed = 0;
|
|
260
|
+
for await (const chunk of opened.body) {
|
|
261
|
+
streamed += chunk.byteLength;
|
|
262
|
+
if (streamed > opened.attachment.sizeBytes) {
|
|
263
|
+
throw new TuiAdapterError("invalid_request", "Reply artifact exceeded its declared size.");
|
|
264
|
+
}
|
|
265
|
+
await writeBinaryChunk(res, chunk);
|
|
266
|
+
}
|
|
267
|
+
if (streamed !== opened.attachment.sizeBytes) {
|
|
268
|
+
throw new TuiAdapterError("invalid_request", "Reply artifact stream ended before its declared size.");
|
|
269
|
+
}
|
|
270
|
+
res.end();
|
|
271
|
+
}).catch((error) => {
|
|
272
|
+
if (res.headersSent) {
|
|
273
|
+
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const code = codeOf(error);
|
|
277
|
+
const status = code === "artifact_forbidden" || code === "artifact_missing" || code === "artifact_expired"
|
|
278
|
+
? 404
|
|
279
|
+
: code === "artifact_integrity_failed" ? 409 : 500;
|
|
280
|
+
sendJsonError(res, status, error);
|
|
281
|
+
}).catch(next);
|
|
282
|
+
});
|
|
283
|
+
app.get(mcpAppPath, (req, res) => {
|
|
284
|
+
if (!authorize(req, res, apiKey))
|
|
285
|
+
return;
|
|
286
|
+
if (typeof options.responder.loadMcpApp !== "function") {
|
|
287
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "MCP Apps are unavailable."));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const identity = normalizeMcpAppIdentity(req);
|
|
291
|
+
if (identity === undefined) {
|
|
292
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "A valid conversation, invocation, and connection id are required."));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
void options.responder.loadMcpApp(identity).then((resource) => {
|
|
296
|
+
setPrivateMcpAppHeaders(res);
|
|
297
|
+
res.status(200).json(resource);
|
|
298
|
+
}).catch((error) => sendMcpAppError(res, error));
|
|
299
|
+
});
|
|
300
|
+
app.post(mcpAppRequestPath, express.json({ limit: MAX_MCP_APP_REQUEST_BYTES, strict: true }), (req, res) => {
|
|
301
|
+
if (!authorize(req, res, apiKey))
|
|
302
|
+
return;
|
|
303
|
+
if (typeof options.responder.requestMcpApp !== "function") {
|
|
304
|
+
sendJsonError(res, 404, new TuiAdapterError("invalid_request", "MCP Apps are unavailable."));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const identity = normalizeMcpAppIdentity(req);
|
|
308
|
+
const body = isRecord(req.body) ? req.body : undefined;
|
|
309
|
+
const method = body?.method;
|
|
310
|
+
const params = body?.params;
|
|
311
|
+
const confirmed = body?.confirmed;
|
|
312
|
+
if (identity === undefined
|
|
313
|
+
|| (method !== "resources/read"
|
|
314
|
+
&& method !== "tools/call"
|
|
315
|
+
&& method !== "ui/open-link"
|
|
316
|
+
&& method !== "ui/update-model-context")
|
|
317
|
+
|| (confirmed !== undefined && typeof confirmed !== "boolean")) {
|
|
318
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "The MCP App bridge request is invalid."));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
void options.responder.requestMcpApp({
|
|
322
|
+
...identity,
|
|
323
|
+
method,
|
|
324
|
+
...(params === undefined ? {} : { params }),
|
|
325
|
+
...(confirmed === undefined ? {} : { confirmed }),
|
|
326
|
+
}).then((result) => {
|
|
327
|
+
setPrivateMcpAppHeaders(res);
|
|
328
|
+
res.status(200).json({ result });
|
|
329
|
+
}).catch((error) => sendMcpAppError(res, error));
|
|
330
|
+
});
|
|
106
331
|
app.post(verbatimPath, express.json({ limit: MAX_VERBATIM_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
107
332
|
if (!authorize(req, res, apiKey)) {
|
|
108
333
|
return;
|
|
@@ -200,6 +425,128 @@ export async function startTuiAdapter(options) {
|
|
|
200
425
|
res.status(200).json(result);
|
|
201
426
|
}).catch((error) => sendJsonError(res, 500, error));
|
|
202
427
|
});
|
|
428
|
+
app.get(interactionPath, (req, res) => {
|
|
429
|
+
if (!authorize(req, res, apiKey))
|
|
430
|
+
return;
|
|
431
|
+
const interactionId = normalizeOptionalString(typeof req.params.interactionId === "string" ? req.params.interactionId : undefined);
|
|
432
|
+
if (interactionId === undefined) {
|
|
433
|
+
sendJsonError(res, 400, new TuiAdapterError("invalid_request", "interactionId is required."));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (typeof options.interaction?.getAsk !== "function") {
|
|
437
|
+
sendJsonError(res, 501, new TuiAdapterError("invalid_request", "Exact interaction lookup is unsupported."));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
void Promise.resolve(options.interaction.getAsk(interactionId))
|
|
441
|
+
.then((ask) => res.status(200).json({ ask: ask ?? null }))
|
|
442
|
+
.catch((error) => sendJsonError(res, 500, error));
|
|
443
|
+
});
|
|
444
|
+
app.get(cronOverviewPath, (req, res, next) => {
|
|
445
|
+
if (!authorize(req, res, apiKey))
|
|
446
|
+
return;
|
|
447
|
+
if (options.cron === undefined) {
|
|
448
|
+
sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
void Promise.resolve(options.cron.overview())
|
|
452
|
+
.then((overview) => sendBoundedCronJson(res, 200, parseCronOperatorOverview(overview)))
|
|
453
|
+
.catch(next);
|
|
454
|
+
});
|
|
455
|
+
app.get(cronRunsPath, (req, res, next) => {
|
|
456
|
+
if (!authorize(req, res, apiKey))
|
|
457
|
+
return;
|
|
458
|
+
if (options.cron === undefined) {
|
|
459
|
+
sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const jobId = cronJobId(req.params.jobId);
|
|
464
|
+
const rawLimit = typeof req.query.limit === "string" ? Number(req.query.limit) : 50;
|
|
465
|
+
if (!Number.isSafeInteger(rawLimit) || rawLimit < 1 || rawLimit > MAX_CRON_OPERATOR_RUN_PAGE) {
|
|
466
|
+
throw new CronOperatorError("invalid_request", `limit must be 1-${String(MAX_CRON_OPERATOR_RUN_PAGE)}.`, 400);
|
|
467
|
+
}
|
|
468
|
+
const before = typeof req.query.before === "string" && req.query.before.length > 0
|
|
469
|
+
? req.query.before
|
|
470
|
+
: undefined;
|
|
471
|
+
if (before !== undefined && Buffer.byteLength(before, "utf8") > 4_096) {
|
|
472
|
+
throw new CronOperatorError("invalid_request", "before cursor is too large.", 400);
|
|
473
|
+
}
|
|
474
|
+
void Promise.resolve(options.cron.runs({ jobId, limit: rawLimit, ...(before === undefined ? {} : { before }) }))
|
|
475
|
+
.then((page) => sendBoundedCronJson(res, 200, parseCronOperatorRunPage(page)))
|
|
476
|
+
.catch(next);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
next(error);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
app.get(cronRunDetailPath, (req, res, next) => {
|
|
483
|
+
if (!authorize(req, res, apiKey))
|
|
484
|
+
return;
|
|
485
|
+
if (options.cron === undefined) {
|
|
486
|
+
sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const jobId = cronJobId(req.params.jobId);
|
|
491
|
+
const runId = cronRunId(req.params.runId);
|
|
492
|
+
void Promise.resolve(options.cron.run({ jobId, runId }))
|
|
493
|
+
.then((run) => sendBoundedCronJson(res, 200, { run: parseCronOperatorRunDetail(run) }))
|
|
494
|
+
.catch(next);
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
next(error);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
app.get(cronConfigViewPath, (req, res, next) => {
|
|
501
|
+
if (!authorize(req, res, apiKey))
|
|
502
|
+
return;
|
|
503
|
+
if (options.cron === undefined) {
|
|
504
|
+
sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
void Promise.resolve(options.cron.configView())
|
|
508
|
+
.then((configView) => res.status(200).json({ configView }))
|
|
509
|
+
.catch(next);
|
|
510
|
+
});
|
|
511
|
+
app.post(cronRunNowPath, express.json({ limit: MAX_CRON_ACTION_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
512
|
+
if (!authorize(req, res, apiKey) || !requireCronActionKey(res, apiKey))
|
|
513
|
+
return;
|
|
514
|
+
if (options.cron === undefined) {
|
|
515
|
+
sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
const jobId = cronJobId(req.params.jobId);
|
|
520
|
+
const action = cronActionInput(req.body);
|
|
521
|
+
void Promise.resolve(options.cron.runNow(jobId, action))
|
|
522
|
+
.then((result) => sendCronMutation(res, result))
|
|
523
|
+
.catch(next);
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
next(error);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
app.post(cronEnabledPath, express.json({ limit: MAX_CRON_ACTION_BODY_BYTES, strict: true }), (req, res, next) => {
|
|
530
|
+
if (!authorize(req, res, apiKey) || !requireCronActionKey(res, apiKey))
|
|
531
|
+
return;
|
|
532
|
+
if (options.cron === undefined) {
|
|
533
|
+
sendJsonError(res, 404, new CronOperatorError("unavailable", "Cron operator capability is unavailable.", 404));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
const jobId = cronJobId(req.params.jobId);
|
|
538
|
+
if (!isRecord(req.body) || typeof req.body.enabled !== "boolean") {
|
|
539
|
+
throw new CronOperatorError("invalid_request", "enabled must be a boolean.", 400);
|
|
540
|
+
}
|
|
541
|
+
const action = cronActionInput(req.body);
|
|
542
|
+
void Promise.resolve(options.cron.setEffectiveEnabled(jobId, req.body.enabled, action))
|
|
543
|
+
.then((result) => sendCronMutation(res, result))
|
|
544
|
+
.catch(next);
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
next(error);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
203
550
|
app.use((error, _req, res, next) => {
|
|
204
551
|
if (res.headersSent) {
|
|
205
552
|
next(error);
|
|
@@ -213,6 +560,10 @@ export async function startTuiAdapter(options) {
|
|
|
213
560
|
}
|
|
214
561
|
// 400 only for client mistakes (invalid_request, body-parse SyntaxError);
|
|
215
562
|
// anything else is a server-side failure and must read as one.
|
|
563
|
+
if (error instanceof CronOperatorError) {
|
|
564
|
+
sendJsonError(res, error.status, error);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
216
567
|
const isClientError = codeOf(error) === "invalid_request" ||
|
|
217
568
|
(error instanceof SyntaxError && error.status === 400);
|
|
218
569
|
sendJsonError(res, isClientError ? 400 : 500, error);
|
|
@@ -276,6 +627,7 @@ export async function startTuiAdapter(options) {
|
|
|
276
627
|
kind: "finish",
|
|
277
628
|
...(response.text === undefined ? {} : { finalText: response.text }),
|
|
278
629
|
...(response.metadata === undefined ? {} : { metadata: response.metadata }),
|
|
630
|
+
...(response.parts === undefined || response.parts.length === 0 ? {} : { parts: response.parts }),
|
|
279
631
|
});
|
|
280
632
|
}
|
|
281
633
|
catch (error) {
|
|
@@ -317,7 +669,9 @@ export async function startTuiAdapter(options) {
|
|
|
317
669
|
* the response's backpressure signal and carry a bounded pending-byte budget,
|
|
318
670
|
* so a slow client cannot grow the process heap without limit. Oversized event
|
|
319
671
|
* frames are reduced or replaced with a marker to meet the exported UTF-8 byte
|
|
320
|
-
* cap;
|
|
672
|
+
* cap. Append text is split losslessly; other text fields are deterministically
|
|
673
|
+
* truncated, and terminal rich parts that cannot fit are replaced by an
|
|
674
|
+
* explicit failure marker.
|
|
321
675
|
*/
|
|
322
676
|
class NdjsonMessageStream {
|
|
323
677
|
res;
|
|
@@ -330,11 +684,30 @@ class NdjsonMessageStream {
|
|
|
330
684
|
if (this.res.writableEnded) {
|
|
331
685
|
return;
|
|
332
686
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
687
|
+
const boundedFrame = frame.kind === "finish" ? capFinishReplyParts(frame) : frame;
|
|
688
|
+
const line = serializeAgentStreamFrame(boundedFrame);
|
|
689
|
+
if (Buffer.byteLength(line, "utf8") <= MAX_FRAME_BYTES) {
|
|
690
|
+
await this.writer.write(line);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (boundedFrame.kind === "append") {
|
|
694
|
+
for (const chunk of splitTextForWireFrame("append", boundedFrame.delta)) {
|
|
695
|
+
await this.writer.write(serializeAgentStreamFrame({ kind: "append", delta: chunk }));
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
336
698
|
}
|
|
337
|
-
|
|
699
|
+
const capped = boundedFrame.kind === "event"
|
|
700
|
+
? serializeCappedEventFrame(boundedFrame.event, line)
|
|
701
|
+
: boundedFrame.kind === "finish"
|
|
702
|
+
? serializeCappedFinishFrame(line)
|
|
703
|
+
: boundedFrame.kind === "status" || boundedFrame.kind === "replace" || boundedFrame.kind === "error"
|
|
704
|
+
? serializeCappedTextFrame(boundedFrame)
|
|
705
|
+
: serializeAgentStreamFrame({
|
|
706
|
+
kind: "error",
|
|
707
|
+
code: "frame_too_large",
|
|
708
|
+
message: "A transport frame exceeded the maximum size.",
|
|
709
|
+
});
|
|
710
|
+
await this.writer.write(capped);
|
|
338
711
|
}
|
|
339
712
|
async status(text) {
|
|
340
713
|
await this.writeFrame({ kind: "status", text });
|
|
@@ -353,6 +726,24 @@ class NdjsonMessageStream {
|
|
|
353
726
|
// AgentResponse (which carries metadata); mid-stream finish() is a no-op.
|
|
354
727
|
}
|
|
355
728
|
}
|
|
729
|
+
function capFinishReplyParts(frame) {
|
|
730
|
+
const parts = frame.parts;
|
|
731
|
+
if (parts === undefined || parts.length <= MAX_AGENT_REPLY_PARTS)
|
|
732
|
+
return frame;
|
|
733
|
+
const omitted = parts.length - (MAX_AGENT_REPLY_PARTS - 1);
|
|
734
|
+
return {
|
|
735
|
+
...frame,
|
|
736
|
+
parts: [
|
|
737
|
+
...parts.slice(0, MAX_AGENT_REPLY_PARTS - 1),
|
|
738
|
+
{
|
|
739
|
+
type: "failure",
|
|
740
|
+
id: "wire-rich-parts-over-limit",
|
|
741
|
+
code: "reply_part_too_large",
|
|
742
|
+
message: `${omitted} rich reply part${omitted === 1 ? " was" : "s were"} omitted because the reply exceeded the 20-part transport limit.`,
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
356
747
|
/**
|
|
357
748
|
* Prepare a stable reducer for the payload-bearing event variants whose shape
|
|
358
749
|
* the operator adapter preserves under truncation. The input is the parsed
|
|
@@ -462,12 +853,217 @@ function serializeOversizedEventMarker(originalType) {
|
|
|
462
853
|
},
|
|
463
854
|
});
|
|
464
855
|
}
|
|
856
|
+
function serializeCappedFinishFrame(serializedFrame) {
|
|
857
|
+
const snapshot = JSON.parse(serializedFrame);
|
|
858
|
+
const safeMetadata = compactFinishMetadata(snapshot.metadata);
|
|
859
|
+
const base = {
|
|
860
|
+
kind: "finish",
|
|
861
|
+
...(safeMetadata === undefined ? {} : { metadata: safeMetadata }),
|
|
862
|
+
};
|
|
863
|
+
const parts = snapshot.parts ?? [];
|
|
864
|
+
const allPartsFrame = {
|
|
865
|
+
...base,
|
|
866
|
+
...(parts.length === 0 ? {} : { parts }),
|
|
867
|
+
};
|
|
868
|
+
const allPartsWithoutText = serializeAgentStreamFrame(allPartsFrame);
|
|
869
|
+
if (Buffer.byteLength(allPartsWithoutText, "utf8") <= MAX_FRAME_BYTES) {
|
|
870
|
+
const withText = serializeFinishWithCappedText(allPartsFrame, snapshot.finalText);
|
|
871
|
+
if (Buffer.byteLength(withText, "utf8") <= MAX_FRAME_BYTES)
|
|
872
|
+
return withText;
|
|
873
|
+
}
|
|
874
|
+
const failure = {
|
|
875
|
+
type: "failure",
|
|
876
|
+
id: "wire-rich-parts-truncated",
|
|
877
|
+
code: "reply_part_too_large",
|
|
878
|
+
message: "One or more rich reply parts were omitted because the terminal frame exceeded 256 KiB.",
|
|
879
|
+
};
|
|
880
|
+
let frame = {
|
|
881
|
+
...base,
|
|
882
|
+
...(snapshot.finalText === undefined ? {} : { finalText: snapshot.finalText }),
|
|
883
|
+
parts: [failure],
|
|
884
|
+
};
|
|
885
|
+
let line = serializeAgentStreamFrame(frame);
|
|
886
|
+
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES && snapshot.finalText !== undefined) {
|
|
887
|
+
frame = {
|
|
888
|
+
...frame,
|
|
889
|
+
finalText: `${largestTextThatFits(snapshot.finalText, (candidate) => serializeAgentStreamFrame({ ...frame, finalText: `${candidate}… [truncated]` }))}… [truncated]`,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const accepted = [];
|
|
893
|
+
for (const part of parts) {
|
|
894
|
+
const candidate = serializeAgentStreamFrame({ ...frame, parts: [...accepted, part, failure] });
|
|
895
|
+
if (Buffer.byteLength(candidate, "utf8") <= MAX_FRAME_BYTES) {
|
|
896
|
+
accepted.push(part);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
line = serializeAgentStreamFrame({
|
|
900
|
+
...frame,
|
|
901
|
+
parts: [...accepted, failure],
|
|
902
|
+
});
|
|
903
|
+
return Buffer.byteLength(line, "utf8") <= MAX_FRAME_BYTES
|
|
904
|
+
? line
|
|
905
|
+
: serializeAgentStreamFrame({ kind: "finish", metadata: { truncated: true } });
|
|
906
|
+
}
|
|
907
|
+
function serializeFinishWithCappedText(frame, finalText) {
|
|
908
|
+
if (finalText === undefined)
|
|
909
|
+
return serializeAgentStreamFrame(frame);
|
|
910
|
+
const complete = serializeAgentStreamFrame({ ...frame, finalText });
|
|
911
|
+
if (Buffer.byteLength(complete, "utf8") <= MAX_FRAME_BYTES)
|
|
912
|
+
return complete;
|
|
913
|
+
const suffix = "… [truncated]";
|
|
914
|
+
const text = largestTextThatFits(finalText, (candidate) => serializeAgentStreamFrame({
|
|
915
|
+
...frame,
|
|
916
|
+
finalText: `${candidate}${suffix}`,
|
|
917
|
+
}));
|
|
918
|
+
return serializeAgentStreamFrame({ ...frame, finalText: `${text}${suffix}` });
|
|
919
|
+
}
|
|
920
|
+
function compactFinishMetadata(metadata) {
|
|
921
|
+
if (metadata === undefined)
|
|
922
|
+
return undefined;
|
|
923
|
+
const compact = { truncated: true };
|
|
924
|
+
for (const key of ["runId", "conversationId", "requestId"]) {
|
|
925
|
+
const value = metadata[key];
|
|
926
|
+
if (typeof value === "string")
|
|
927
|
+
compact[key] = value.slice(0, 512);
|
|
928
|
+
}
|
|
929
|
+
return compact;
|
|
930
|
+
}
|
|
931
|
+
function serializeCappedTextFrame(frame) {
|
|
932
|
+
const suffix = "… [truncated]";
|
|
933
|
+
if (frame.kind === "error") {
|
|
934
|
+
const text = largestTextThatFits(frame.message, (candidate) => serializeAgentStreamFrame({
|
|
935
|
+
...frame,
|
|
936
|
+
message: `${candidate}${suffix}`,
|
|
937
|
+
}));
|
|
938
|
+
return serializeAgentStreamFrame({ ...frame, message: `${text}${suffix}` });
|
|
939
|
+
}
|
|
940
|
+
const text = largestTextThatFits(frame.text, (candidate) => serializeAgentStreamFrame({
|
|
941
|
+
...frame,
|
|
942
|
+
text: `${candidate}${suffix}`,
|
|
943
|
+
}));
|
|
944
|
+
return serializeAgentStreamFrame({ ...frame, text: `${text}${suffix}` });
|
|
945
|
+
}
|
|
946
|
+
function splitTextForWireFrame(kind, text) {
|
|
947
|
+
if (text.length === 0)
|
|
948
|
+
return [""];
|
|
949
|
+
const chunks = [];
|
|
950
|
+
let remaining = text;
|
|
951
|
+
while (remaining.length > 0) {
|
|
952
|
+
const chunk = largestTextThatFits(remaining, (candidate) => serializeAgentStreamFrame({ kind, delta: candidate }));
|
|
953
|
+
if (chunk.length === 0)
|
|
954
|
+
break;
|
|
955
|
+
chunks.push(chunk);
|
|
956
|
+
remaining = remaining.slice(chunk.length);
|
|
957
|
+
}
|
|
958
|
+
return chunks;
|
|
959
|
+
}
|
|
960
|
+
function largestTextThatFits(text, serialize) {
|
|
961
|
+
let lower = 0;
|
|
962
|
+
let upper = text.length;
|
|
963
|
+
let best = "";
|
|
964
|
+
while (lower <= upper) {
|
|
965
|
+
let length = Math.floor((lower + upper) / 2);
|
|
966
|
+
if (length > 0 && isHighSurrogate(text.charCodeAt(length - 1)))
|
|
967
|
+
length -= 1;
|
|
968
|
+
const candidate = text.slice(0, length);
|
|
969
|
+
if (Buffer.byteLength(serialize(candidate), "utf8") <= MAX_FRAME_BYTES) {
|
|
970
|
+
best = candidate;
|
|
971
|
+
lower = Math.max(lower + 1, length + 1);
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
upper = length - 1;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return best;
|
|
978
|
+
}
|
|
979
|
+
function isHighSurrogate(code) {
|
|
980
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
981
|
+
}
|
|
465
982
|
function serializeUnknown(value) {
|
|
466
983
|
return typeof value === "string" ? value : JSON.stringify(value) ?? "";
|
|
467
984
|
}
|
|
468
985
|
function truncatePreparedText(text, cap) {
|
|
469
986
|
return text.length > cap ? `${text.slice(0, cap)}… [truncated]` : text;
|
|
470
987
|
}
|
|
988
|
+
function setReplyArtifactHeaders(res, attachment) {
|
|
989
|
+
const asciiName = attachment.name
|
|
990
|
+
.replace(/[^\x20-\x7e]/gu, "_")
|
|
991
|
+
.replace(/["\\]/gu, "_")
|
|
992
|
+
.slice(0, 180) || "attachment";
|
|
993
|
+
const encodedName = encodeURIComponent(attachment.name).replace(/['()*]/gu, (character) => `%${character.codePointAt(0).toString(16).toUpperCase()}`);
|
|
994
|
+
res.status(200);
|
|
995
|
+
const risky = /^(?:text\/(?:html|javascript|xml)|application\/(?:javascript|xhtml\+xml|xml)|image\/svg\+xml)$/iu
|
|
996
|
+
.test(attachment.mediaType);
|
|
997
|
+
res.setHeader("Content-Type", risky ? "application/octet-stream" : attachment.mediaType);
|
|
998
|
+
if (risky)
|
|
999
|
+
res.setHeader("X-Original-Content-Type", attachment.mediaType);
|
|
1000
|
+
res.setHeader("Content-Length", String(attachment.sizeBytes));
|
|
1001
|
+
res.setHeader("Accept-Ranges", "none");
|
|
1002
|
+
res.setHeader("Content-Disposition", `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
|
|
1003
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
1004
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1005
|
+
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
|
|
1006
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1007
|
+
res.setHeader("X-Mono-Agent-Integrity-Id", attachment.integrityId);
|
|
1008
|
+
}
|
|
1009
|
+
async function writeBinaryChunk(res, chunk) {
|
|
1010
|
+
if (res.destroyed || res.writableEnded)
|
|
1011
|
+
throw new Error("Reply artifact client disconnected.");
|
|
1012
|
+
if (res.write(Buffer.from(chunk)))
|
|
1013
|
+
return;
|
|
1014
|
+
await new Promise((resolveDrain, rejectDrain) => {
|
|
1015
|
+
const cleanup = () => {
|
|
1016
|
+
clearTimeout(timer);
|
|
1017
|
+
res.off("drain", onDrain);
|
|
1018
|
+
res.off("close", onClose);
|
|
1019
|
+
res.off("error", onError);
|
|
1020
|
+
};
|
|
1021
|
+
const onDrain = () => { cleanup(); resolveDrain(); };
|
|
1022
|
+
const onClose = () => { cleanup(); rejectDrain(new Error("Reply artifact client disconnected.")); };
|
|
1023
|
+
const onError = (error) => { cleanup(); rejectDrain(error); };
|
|
1024
|
+
const timer = setTimeout(() => {
|
|
1025
|
+
cleanup();
|
|
1026
|
+
rejectDrain(new Error("Reply artifact response did not drain in time."));
|
|
1027
|
+
}, REPLY_ARTIFACT_DRAIN_TIMEOUT_MS);
|
|
1028
|
+
res.once("drain", onDrain);
|
|
1029
|
+
res.once("close", onClose);
|
|
1030
|
+
res.once("error", onError);
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
function normalizeMcpAppIdentity(req) {
|
|
1034
|
+
const conversationId = normalizeOptionalString(typeof req.params.conversationId === "string" ? req.params.conversationId : undefined);
|
|
1035
|
+
const invocationId = normalizeOptionalString(typeof req.params.invocationId === "string" ? req.params.invocationId : undefined);
|
|
1036
|
+
const connectionId = normalizeOptionalString(req.header("x-mono-agent-mcp-connection-id"));
|
|
1037
|
+
if (conversationId === undefined
|
|
1038
|
+
|| invocationId === undefined
|
|
1039
|
+
|| connectionId === undefined
|
|
1040
|
+
|| Buffer.byteLength(conversationId, "utf8") > MAX_MCP_APP_IDENTITY_BYTES
|
|
1041
|
+
|| Buffer.byteLength(invocationId, "utf8") > MAX_MCP_APP_IDENTITY_BYTES
|
|
1042
|
+
|| Buffer.byteLength(connectionId, "utf8") > MAX_MCP_APP_IDENTITY_BYTES)
|
|
1043
|
+
return undefined;
|
|
1044
|
+
return { conversationId, invocationId, connectionId };
|
|
1045
|
+
}
|
|
1046
|
+
function setPrivateMcpAppHeaders(res) {
|
|
1047
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
1048
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1049
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
|
1050
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1051
|
+
}
|
|
1052
|
+
function sendMcpAppError(res, error) {
|
|
1053
|
+
const code = codeOf(error);
|
|
1054
|
+
const status = code === "app_forbidden" || code === "app_missing" || code === "app_expired"
|
|
1055
|
+
? 404
|
|
1056
|
+
: code === "app_request_too_large" ? 413
|
|
1057
|
+
: code === "app_tool_forbidden" || code === "app_resource_forbidden" || code === "app_open_link_forbidden" ? 403
|
|
1058
|
+
: code === "app_confirmation_required" ? 409
|
|
1059
|
+
: code === "app_audit_incomplete" ? 409
|
|
1060
|
+
: code === "app_rate_limited" ? 429
|
|
1061
|
+
: code === "app_connection_closed" ? 410
|
|
1062
|
+
: code === "app_audit_failed" ? 507
|
|
1063
|
+
: 500;
|
|
1064
|
+
setPrivateMcpAppHeaders(res);
|
|
1065
|
+
sendJsonError(res, status, error);
|
|
1066
|
+
}
|
|
471
1067
|
function normalizeVerbatimBody(rawConversationId, body) {
|
|
472
1068
|
const conversationId = normalizeOptionalString(typeof rawConversationId === "string" ? rawConversationId : undefined);
|
|
473
1069
|
if (conversationId === undefined) {
|
|
@@ -753,6 +1349,56 @@ async function resolveInfo(info) {
|
|
|
753
1349
|
}
|
|
754
1350
|
return info;
|
|
755
1351
|
}
|
|
1352
|
+
function cronJobId(value) {
|
|
1353
|
+
const raw = typeof value === "string" ? value : undefined;
|
|
1354
|
+
const jobId = normalizeOptionalString(raw);
|
|
1355
|
+
if (jobId === undefined || Buffer.byteLength(jobId, "utf8") > 512) {
|
|
1356
|
+
throw new CronOperatorError("invalid_request", "A valid cron job id is required.", 400);
|
|
1357
|
+
}
|
|
1358
|
+
return jobId;
|
|
1359
|
+
}
|
|
1360
|
+
function cronRunId(value) {
|
|
1361
|
+
const raw = typeof value === "string" ? value : undefined;
|
|
1362
|
+
const runId = normalizeOptionalString(raw);
|
|
1363
|
+
if (runId === undefined || Buffer.byteLength(runId, "utf8") > 4_096) {
|
|
1364
|
+
throw new CronOperatorError("invalid_request", "A valid cron run id is required.", 400);
|
|
1365
|
+
}
|
|
1366
|
+
return runId;
|
|
1367
|
+
}
|
|
1368
|
+
function cronActionInput(value) {
|
|
1369
|
+
if (!isRecord(value)) {
|
|
1370
|
+
throw new CronOperatorError("invalid_request", "A JSON action body is required.", 400);
|
|
1371
|
+
}
|
|
1372
|
+
const idempotencyKey = normalizeOptionalString(typeof value.idempotencyKey === "string" ? value.idempotencyKey : undefined);
|
|
1373
|
+
if (idempotencyKey === undefined || Buffer.byteLength(idempotencyKey, "utf8") > 256) {
|
|
1374
|
+
throw new CronOperatorError("invalid_request", "A valid idempotencyKey is required.", 400);
|
|
1375
|
+
}
|
|
1376
|
+
const confirmationToken = normalizeOptionalString(typeof value.confirmationToken === "string" ? value.confirmationToken : undefined);
|
|
1377
|
+
if (confirmationToken !== undefined && Buffer.byteLength(confirmationToken, "utf8") > 1_024) {
|
|
1378
|
+
throw new CronOperatorError("invalid_request", "confirmationToken is too large.", 400);
|
|
1379
|
+
}
|
|
1380
|
+
return { idempotencyKey, ...(confirmationToken === undefined ? {} : { confirmationToken }) };
|
|
1381
|
+
}
|
|
1382
|
+
function requireCronActionKey(res, apiKey) {
|
|
1383
|
+
if (apiKey !== undefined)
|
|
1384
|
+
return true;
|
|
1385
|
+
sendJsonError(res, 403, new CronOperatorError("actions_disabled", "Cron actions require an operator API key.", 403));
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
function sendCronMutation(res, result) {
|
|
1389
|
+
if (result.kind === "confirmation_required") {
|
|
1390
|
+
sendBoundedCronJson(res, 428, result);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
sendBoundedCronJson(res, 200, result);
|
|
1394
|
+
}
|
|
1395
|
+
function sendBoundedCronJson(res, status, value) {
|
|
1396
|
+
const serialized = JSON.stringify(value);
|
|
1397
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_CRON_OPERATOR_RESPONSE_BYTES) {
|
|
1398
|
+
throw new CronOperatorError("unavailable", "Cron operator response exceeded its bounded wire contract.", 503);
|
|
1399
|
+
}
|
|
1400
|
+
res.status(status).type("application/json").send(serialized);
|
|
1401
|
+
}
|
|
756
1402
|
function authorize(req, res, apiKey) {
|
|
757
1403
|
if (apiKey === undefined) {
|
|
758
1404
|
return true;
|
|
@@ -772,6 +1418,64 @@ function sendJsonError(res, status, error) {
|
|
|
772
1418
|
},
|
|
773
1419
|
});
|
|
774
1420
|
}
|
|
1421
|
+
function sendBoundedJobs(res, jobs) {
|
|
1422
|
+
const body = serializeBoundedJobs(jobs);
|
|
1423
|
+
if (body === undefined) {
|
|
1424
|
+
sendJsonError(res, 413, new TuiAdapterError("process_job_response_too_large", `Process-job list exceeds the ${String(MAX_PROCESS_JOBS_RESPONSE_BYTES)}-byte operator response bound.`));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
res.status(200).type("application/json").send(body);
|
|
1428
|
+
}
|
|
1429
|
+
function serializeBoundedJobs(jobs) {
|
|
1430
|
+
const parsed = parseProcessJobProjections(jobs);
|
|
1431
|
+
const selected = new Map();
|
|
1432
|
+
let selectedCount = 0;
|
|
1433
|
+
let bodyBytes = Buffer.byteLength('{"jobs":[]}', "utf8");
|
|
1434
|
+
const add = (index, projection) => {
|
|
1435
|
+
const serialized = JSON.stringify(projection);
|
|
1436
|
+
const nextBytes = bodyBytes
|
|
1437
|
+
+ Buffer.byteLength(serialized, "utf8")
|
|
1438
|
+
+ (selectedCount === 0 ? 0 : 1);
|
|
1439
|
+
if (nextBytes > MAX_PROCESS_JOBS_RESPONSE_BYTES)
|
|
1440
|
+
return false;
|
|
1441
|
+
selected.set(index, serialized);
|
|
1442
|
+
selectedCount += 1;
|
|
1443
|
+
bodyBytes = nextBytes;
|
|
1444
|
+
return true;
|
|
1445
|
+
};
|
|
1446
|
+
// The app can retain up to 32 starting/running and 64 queued records
|
|
1447
|
+
// alongside its terminal ceiling. Keep that complete control-plane view
|
|
1448
|
+
// even when large terminal projections require a smaller HTTP representation.
|
|
1449
|
+
for (const [index, projection] of parsed.entries()) {
|
|
1450
|
+
if (isActiveProcessJobProjection(projection) && !add(index, projection))
|
|
1451
|
+
return undefined;
|
|
1452
|
+
}
|
|
1453
|
+
// Service lists are already newest-first. Select one deterministic terminal
|
|
1454
|
+
// prefix so a larger byte budget can only extend, never reshuffle, history.
|
|
1455
|
+
for (const [index, projection] of parsed.entries()) {
|
|
1456
|
+
if (isActiveProcessJobProjection(projection))
|
|
1457
|
+
continue;
|
|
1458
|
+
if (!add(index, projection))
|
|
1459
|
+
break;
|
|
1460
|
+
}
|
|
1461
|
+
const serialized = [...selected.entries()]
|
|
1462
|
+
.sort(([left], [right]) => left - right)
|
|
1463
|
+
.map(([, projection]) => projection);
|
|
1464
|
+
return `{"jobs":[${serialized.join(",")}]}`;
|
|
1465
|
+
}
|
|
1466
|
+
function isActiveProcessJobProjection(projection) {
|
|
1467
|
+
return projection.state === "queued"
|
|
1468
|
+
|| projection.state === "starting"
|
|
1469
|
+
|| projection.state === "running";
|
|
1470
|
+
}
|
|
1471
|
+
function sendBoundedJob(res, job) {
|
|
1472
|
+
const body = JSON.stringify(parseProcessJobProjection(job));
|
|
1473
|
+
if (Buffer.byteLength(body, "utf8") > MAX_PROCESS_JOBS_RESPONSE_BYTES) {
|
|
1474
|
+
sendJsonError(res, 413, new TuiAdapterError("process_job_response_too_large", `Process-job projection exceeds the ${String(MAX_PROCESS_JOBS_RESPONSE_BYTES)}-byte operator response bound.`));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
res.status(200).type("application/json").send(body);
|
|
1478
|
+
}
|
|
775
1479
|
function codeOf(error) {
|
|
776
1480
|
const candidate = error?.code;
|
|
777
1481
|
return typeof candidate === "string" ? candidate : undefined;
|