@tradejs/app 3.1.29-beta.273 → 3.1.29-beta.276
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/package.json +6 -6
- package/src/app/lib/mcp/artifacts.ts +122 -8
- package/src/app/lib/mcp/server.ts +47 -2
- package/src/app/lib/mcp/verification.ts +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tradejs/app",
|
|
3
|
-
"version": "3.1.29-beta.
|
|
3
|
+
"version": "3.1.29-beta.276",
|
|
4
4
|
"description": "Installable Next.js UI for the TradeJS TypeScript framework: dashboards, backtests, charts, and runtime data.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tradejs",
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
"@emotion/react": "^11.14.0",
|
|
60
60
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
61
61
|
"@node-oauth/oauth2-server": "^5.3.0",
|
|
62
|
-
"@tradejs/core": "^3.1.29-beta.
|
|
63
|
-
"@tradejs/indicators": "^3.1.29-beta.
|
|
64
|
-
"@tradejs/infra": "^3.1.29-beta.
|
|
65
|
-
"@tradejs/node": "^3.1.29-beta.
|
|
66
|
-
"@tradejs/types": "^3.1.29-beta.
|
|
62
|
+
"@tradejs/core": "^3.1.29-beta.276",
|
|
63
|
+
"@tradejs/indicators": "^3.1.29-beta.276",
|
|
64
|
+
"@tradejs/infra": "^3.1.29-beta.276",
|
|
65
|
+
"@tradejs/node": "^3.1.29-beta.276",
|
|
66
|
+
"@tradejs/types": "^3.1.29-beta.276",
|
|
67
67
|
"@types/bcryptjs": "2.4.6",
|
|
68
68
|
"@types/lodash": "4.17.24",
|
|
69
69
|
"@types/node": "24.13.3",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
mcpId,
|
|
11
12
|
mcpProjectRoot,
|
|
12
13
|
readMcpDeployments,
|
|
14
|
+
redactMcpJobLog,
|
|
13
15
|
} from '@tradejs/node/mcp';
|
|
14
16
|
|
|
15
17
|
interface CatalogEntry {
|
|
@@ -107,6 +109,8 @@ export interface McpArtifactListOptions {
|
|
|
107
109
|
kind?: CatalogEntry['kind'];
|
|
108
110
|
offset?: number;
|
|
109
111
|
limit?: number;
|
|
112
|
+
startTime?: number;
|
|
113
|
+
endTime?: number;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
export const listMcpArtifacts = async (
|
|
@@ -123,6 +127,15 @@ export const listMcpArtifacts = async (
|
|
|
123
127
|
limit > 100
|
|
124
128
|
)
|
|
125
129
|
throw new Error('Invalid artifact list pagination');
|
|
130
|
+
if (
|
|
131
|
+
(options.startTime !== undefined &&
|
|
132
|
+
!Number.isSafeInteger(options.startTime)) ||
|
|
133
|
+
(options.endTime !== undefined && !Number.isSafeInteger(options.endTime)) ||
|
|
134
|
+
(options.startTime !== undefined &&
|
|
135
|
+
options.endTime !== undefined &&
|
|
136
|
+
options.startTime >= options.endTime)
|
|
137
|
+
)
|
|
138
|
+
throw new Error('Invalid artifact window');
|
|
126
139
|
const { deployments } = await readMcpDeployments(userName);
|
|
127
140
|
const allowed = new Set(deployments.map((row) => row.id));
|
|
128
141
|
const pending = [
|
|
@@ -151,6 +164,20 @@ export const listMcpArtifacts = async (
|
|
|
151
164
|
)
|
|
152
165
|
continue;
|
|
153
166
|
if (options.kind && artifact.kind !== options.kind) continue;
|
|
167
|
+
const window = artifact.manifest.window as {
|
|
168
|
+
startTime: number;
|
|
169
|
+
endTime: number;
|
|
170
|
+
};
|
|
171
|
+
if (
|
|
172
|
+
options.startTime !== undefined &&
|
|
173
|
+
window.endTime <= options.startTime
|
|
174
|
+
)
|
|
175
|
+
continue;
|
|
176
|
+
if (
|
|
177
|
+
options.endTime !== undefined &&
|
|
178
|
+
window.startTime >= options.endTime
|
|
179
|
+
)
|
|
180
|
+
continue;
|
|
154
181
|
await mcpStorage.put(storageKey(userName, artifact.id), artifact, 3600);
|
|
155
182
|
items.push({
|
|
156
183
|
id: artifact.id,
|
|
@@ -158,7 +185,10 @@ export const listMcpArtifacts = async (
|
|
|
158
185
|
deploymentId: artifact.deploymentId,
|
|
159
186
|
sha256: artifact.sha256,
|
|
160
187
|
bytes: artifact.bytes,
|
|
161
|
-
window
|
|
188
|
+
window,
|
|
189
|
+
artifactId: artifact.manifest.artifactId,
|
|
190
|
+
deploymentCompositionId: artifact.manifest.deploymentCompositionId,
|
|
191
|
+
runtimeEvidence: artifact.manifest.runtimeEvidence,
|
|
162
192
|
});
|
|
163
193
|
} catch {
|
|
164
194
|
invalidBundles++;
|
|
@@ -173,7 +203,11 @@ export const listMcpArtifacts = async (
|
|
|
173
203
|
});
|
|
174
204
|
}
|
|
175
205
|
}
|
|
176
|
-
items.sort(
|
|
206
|
+
items.sort(
|
|
207
|
+
(left, right) =>
|
|
208
|
+
right.window.endTime - left.window.endTime ||
|
|
209
|
+
left.id.localeCompare(right.id),
|
|
210
|
+
);
|
|
177
211
|
const page = items.slice(offset, offset + limit);
|
|
178
212
|
const nextOffset =
|
|
179
213
|
offset + page.length < items.length ? offset + page.length : null;
|
|
@@ -186,7 +220,12 @@ export const listMcpArtifacts = async (
|
|
|
186
220
|
truncated: pending.length > 0 || nextOffset != null,
|
|
187
221
|
scanTruncated: pending.length > 0,
|
|
188
222
|
invalidOrInaccessibleBundles: invalidBundles,
|
|
189
|
-
|
|
223
|
+
order: 'window.endTime_desc_id_asc',
|
|
224
|
+
coverage: {
|
|
225
|
+
retentionStartTime: null,
|
|
226
|
+
completeness: 'host_local_verified_bundles_only',
|
|
227
|
+
},
|
|
228
|
+
note: 'Only verified, user-owned bundles available on this host are included. Excluded bundle counts combine inaccessible and invalid data; reasons and paths are withheld to protect other users. Missing bundles do not prove no activity.',
|
|
190
229
|
};
|
|
191
230
|
};
|
|
192
231
|
|
|
@@ -205,28 +244,84 @@ export const resolveMcpArtifact = async (userName: string, id: string) => {
|
|
|
205
244
|
return verified;
|
|
206
245
|
};
|
|
207
246
|
|
|
247
|
+
type ArtifactFile = 'payload' | 'manifest.json' | '.complete' | 'replay.log';
|
|
248
|
+
|
|
249
|
+
const artifactFile = async (artifact: CatalogEntry, file: ArtifactFile) => {
|
|
250
|
+
const target =
|
|
251
|
+
file === 'payload'
|
|
252
|
+
? artifact.payloadPath
|
|
253
|
+
: path.join(artifact.bundleDir, file);
|
|
254
|
+
if (
|
|
255
|
+
!['payload', 'manifest.json', '.complete', 'replay.log'].includes(file) ||
|
|
256
|
+
(file === 'replay.log' && artifact.kind !== 'runtime-feedback-replay')
|
|
257
|
+
)
|
|
258
|
+
throw new Error('Invalid artifact file');
|
|
259
|
+
const resolved = await checkPayload(target);
|
|
260
|
+
if (file === 'payload')
|
|
261
|
+
return {
|
|
262
|
+
path: resolved,
|
|
263
|
+
file,
|
|
264
|
+
bytes: artifact.bytes,
|
|
265
|
+
sha256: artifact.sha256,
|
|
266
|
+
};
|
|
267
|
+
const bytes = await readFile(resolved);
|
|
268
|
+
// Log files are original bundle bytes. Refuse export if credential redaction
|
|
269
|
+
// would be required; never silently change bytes advertised for verification.
|
|
270
|
+
if (
|
|
271
|
+
file === 'replay.log' &&
|
|
272
|
+
bytes
|
|
273
|
+
.toString('utf8')
|
|
274
|
+
.split('\n')
|
|
275
|
+
.some(
|
|
276
|
+
(line) =>
|
|
277
|
+
(line.length <= 1000 && redactMcpJobLog(line) !== line) ||
|
|
278
|
+
line.length > 1000,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
throw new Error(
|
|
282
|
+
'Invalid artifact file: log requires redaction; use job_get_logs',
|
|
283
|
+
);
|
|
284
|
+
return {
|
|
285
|
+
path: resolved,
|
|
286
|
+
file,
|
|
287
|
+
bytes: bytes.length,
|
|
288
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
|
|
208
292
|
export const readMcpArtifactChunk = async (
|
|
209
293
|
userName: string,
|
|
210
294
|
id: string,
|
|
211
295
|
offset: number,
|
|
212
296
|
length: number,
|
|
297
|
+
file: ArtifactFile = 'payload',
|
|
213
298
|
) => {
|
|
299
|
+
if (
|
|
300
|
+
!Number.isSafeInteger(offset) ||
|
|
301
|
+
offset < 0 ||
|
|
302
|
+
!Number.isSafeInteger(length) ||
|
|
303
|
+
length < 1
|
|
304
|
+
)
|
|
305
|
+
throw new Error('Invalid artifact chunk range');
|
|
214
306
|
const artifact = await resolveMcpArtifact(userName, id);
|
|
215
|
-
const
|
|
307
|
+
const selected = await artifactFile(artifact, file);
|
|
308
|
+
if (offset > selected.bytes) throw new Error('Invalid artifact chunk offset');
|
|
309
|
+
const handle = await open(selected.path, 'r');
|
|
216
310
|
try {
|
|
217
311
|
const buffer = Buffer.alloc(
|
|
218
|
-
Math.min(length, Math.max(0,
|
|
312
|
+
Math.min(length, Math.max(0, selected.bytes - offset)),
|
|
219
313
|
);
|
|
220
314
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
221
315
|
return {
|
|
222
316
|
id,
|
|
223
317
|
offset,
|
|
224
|
-
|
|
225
|
-
|
|
318
|
+
file,
|
|
319
|
+
bytes: selected.bytes,
|
|
320
|
+
sha256: selected.sha256,
|
|
226
321
|
encoding: 'base64',
|
|
227
322
|
data: buffer.subarray(0, bytesRead).toString('base64'),
|
|
228
323
|
nextOffset:
|
|
229
|
-
offset + bytesRead <
|
|
324
|
+
offset + bytesRead < selected.bytes ? offset + bytesRead : null,
|
|
230
325
|
};
|
|
231
326
|
} finally {
|
|
232
327
|
await handle.close();
|
|
@@ -236,10 +331,29 @@ export const readMcpArtifactChunk = async (
|
|
|
236
331
|
export const readMcpDiagnosticReport = async (userName: string, id: string) => {
|
|
237
332
|
const artifact = await resolveMcpArtifact(userName, id);
|
|
238
333
|
const payload = JSON.parse(await readFile(artifact.payloadPath, 'utf8'));
|
|
334
|
+
const files = [];
|
|
335
|
+
for (const file of [
|
|
336
|
+
'payload',
|
|
337
|
+
'manifest.json',
|
|
338
|
+
'.complete',
|
|
339
|
+
...(artifact.kind === 'runtime-feedback-replay' ? ['replay.log'] : []),
|
|
340
|
+
] as ArtifactFile[]) {
|
|
341
|
+
try {
|
|
342
|
+
const { path: _path, ...descriptor } = await artifactFile(artifact, file);
|
|
343
|
+
files.push({ ...descriptor, available: true });
|
|
344
|
+
} catch {
|
|
345
|
+
files.push({
|
|
346
|
+
file,
|
|
347
|
+
available: false,
|
|
348
|
+
reason: 'file_unavailable_or_requires_redaction',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
239
352
|
// Large reports stay available through artifact_get with original checksums.
|
|
240
353
|
return {
|
|
241
354
|
id,
|
|
242
355
|
manifest: artifact.manifest,
|
|
356
|
+
files,
|
|
243
357
|
bytes: artifact.bytes,
|
|
244
358
|
sha256: artifact.sha256,
|
|
245
359
|
...(artifact.bytes <= 96000
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDeploymentVerification } from './verification';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
cancelMcpJob,
|
|
7
8
|
enqueueMcpJob,
|
|
8
9
|
mcpId,
|
|
10
|
+
McpCursorError,
|
|
9
11
|
publicMcpJob,
|
|
10
12
|
readAuthorizedMcpJob,
|
|
11
13
|
readMcpBacktestPage,
|
|
@@ -82,6 +84,7 @@ const runtimePage = {
|
|
|
82
84
|
endTime: timestamp,
|
|
83
85
|
strategy: identifier.optional(),
|
|
84
86
|
symbol: symbol.optional(),
|
|
87
|
+
limit: z.number().int().min(1).max(50).default(50),
|
|
85
88
|
};
|
|
86
89
|
|
|
87
90
|
export const createTradejsMcpServer = (principal: McpPrincipal) => {
|
|
@@ -165,6 +168,21 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
|
|
|
165
168
|
structuredContent: { data: result },
|
|
166
169
|
};
|
|
167
170
|
} catch (error) {
|
|
171
|
+
if (error instanceof McpCursorError) {
|
|
172
|
+
const data = {
|
|
173
|
+
code: error.code,
|
|
174
|
+
message: error.message,
|
|
175
|
+
recovery:
|
|
176
|
+
error.code === 'CURSOR_EXPIRED'
|
|
177
|
+
? 'Restart cursor=0 with the same window and filters; deduplicate record identities. Do not move startTime to the last record: storage order is not chronological.'
|
|
178
|
+
: 'Restore the original user, account and filters or start a new traversal.',
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
isError: true,
|
|
182
|
+
content: [{ type: 'text' as const, text: JSON.stringify(data) }],
|
|
183
|
+
structuredContent: { data },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
168
186
|
const message = error instanceof Error ? error.message : '';
|
|
169
187
|
// Filesystem, connector and database errors may contain secrets or paths.
|
|
170
188
|
const safe =
|
|
@@ -258,6 +276,27 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
|
|
|
258
276
|
{},
|
|
259
277
|
async () => readMcpDeployments(principal.userName),
|
|
260
278
|
);
|
|
279
|
+
tool(
|
|
280
|
+
'runtime_get_verification',
|
|
281
|
+
'Read the checksum-verified Deploy-owned package manifest, actual image digest, deployment verification and health observation. fresh=false means the collector is stale; heartbeat is not a replacement. No job or runtime mutation is performed.',
|
|
282
|
+
['runtime:read'],
|
|
283
|
+
{ deploymentId: identifier },
|
|
284
|
+
async (args) => {
|
|
285
|
+
const deployment = await requireMcpDeployment(
|
|
286
|
+
principal.userName,
|
|
287
|
+
args.deploymentId,
|
|
288
|
+
);
|
|
289
|
+
return readDeploymentVerification(
|
|
290
|
+
String(process.env.PROJECT_CWD || process.cwd()),
|
|
291
|
+
{
|
|
292
|
+
userName: principal.userName,
|
|
293
|
+
deploymentId: deployment.id,
|
|
294
|
+
accountId: deployment.accountId,
|
|
295
|
+
deploymentCompositionId: deployment.deploymentCompositionId,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
);
|
|
261
300
|
tool(
|
|
262
301
|
'runtime_list_strategies',
|
|
263
302
|
'Read effective strategies, configs and immutable revisions for a deployment bound to this user account.',
|
|
@@ -268,7 +307,7 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
|
|
|
268
307
|
for (const kind of ['signals', 'evaluations', 'orders'] as const) {
|
|
269
308
|
tool(
|
|
270
309
|
`runtime_list_${kind}`,
|
|
271
|
-
`Read a bounded storage page of ${kind}. Window is half-open [startTime,endTime), at most 7 days. Continue even after an empty page until cursor=0.`,
|
|
310
|
+
`Read a bounded storage page of ${kind}. Window is half-open [startTime,endTime), at most 7 days. Continue even after an empty page until cursor=0. Shared cursors last 24 hours; retry the identical request after transport errors. Storage order is not chronological; CURSOR_EXPIRED requires restarting the same window and deduplicating identities.`,
|
|
272
311
|
['runtime:read'],
|
|
273
312
|
runtimePage,
|
|
274
313
|
async (args) => {
|
|
@@ -401,6 +440,8 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
|
|
|
401
440
|
kind: z.enum(['runtime-evidence', 'runtime-feedback-replay']).optional(),
|
|
402
441
|
offset: z.number().int().min(0).default(0),
|
|
403
442
|
limit: z.number().int().min(1).max(100).default(20),
|
|
443
|
+
startTime: timestamp.optional(),
|
|
444
|
+
endTime: timestamp.optional(),
|
|
404
445
|
},
|
|
405
446
|
async (args) => listMcpArtifacts(principal.userName, args),
|
|
406
447
|
);
|
|
@@ -414,12 +455,15 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
|
|
|
414
455
|
);
|
|
415
456
|
tool(
|
|
416
457
|
'artifact_get',
|
|
417
|
-
'Read an owned verified artifact in base64 chunks. Concatenate decoded bytes, verify total size and SHA-256 before local analysis. No filesystem path is accepted.',
|
|
458
|
+
'Read an owned verified artifact in base64 chunks. Concatenate decoded bytes, verify total size and SHA-256 before local analysis. Select a file listed by diagnostics_get_report (payload by default). Each file has its own size and SHA-256. No filesystem path is accepted.',
|
|
418
459
|
['diagnostics:read'],
|
|
419
460
|
{
|
|
420
461
|
artifactId,
|
|
421
462
|
offset: z.number().int().min(0).default(0),
|
|
422
463
|
length: z.number().int().min(1).max(48000).default(48000),
|
|
464
|
+
file: z
|
|
465
|
+
.enum(['payload', 'manifest.json', '.complete', 'replay.log'])
|
|
466
|
+
.default('payload'),
|
|
423
467
|
},
|
|
424
468
|
async (args) =>
|
|
425
469
|
readMcpArtifactChunk(
|
|
@@ -427,6 +471,7 @@ export const createTradejsMcpServer = (principal: McpPrincipal) => {
|
|
|
427
471
|
args.artifactId,
|
|
428
472
|
args.offset,
|
|
429
473
|
args.length,
|
|
474
|
+
args.file,
|
|
430
475
|
),
|
|
431
476
|
);
|
|
432
477
|
tool(
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
type Binding = {
|
|
6
|
+
userName: string;
|
|
7
|
+
deploymentId: string;
|
|
8
|
+
accountId: string;
|
|
9
|
+
deploymentCompositionId: string;
|
|
10
|
+
};
|
|
11
|
+
const hash = (bytes: Buffer) =>
|
|
12
|
+
createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
|
|
14
|
+
export const parseDeploymentVerification = (
|
|
15
|
+
bytes: Buffer,
|
|
16
|
+
pointer: { sha256: string; bytes: number },
|
|
17
|
+
binding: Binding,
|
|
18
|
+
manifestBytes: Buffer,
|
|
19
|
+
imageDigest: string,
|
|
20
|
+
now = Date.now(),
|
|
21
|
+
) => {
|
|
22
|
+
if (bytes.length !== pointer.bytes || hash(bytes) !== pointer.sha256)
|
|
23
|
+
throw Error('MCP verification checksum mismatch');
|
|
24
|
+
const report = JSON.parse(bytes.toString('utf8'));
|
|
25
|
+
if (
|
|
26
|
+
report.schemaVersion !== 1 ||
|
|
27
|
+
report.reportType !== 'runtime-deployment-verification' ||
|
|
28
|
+
Object.entries(binding).some(([key, value]) => report[key] !== value)
|
|
29
|
+
)
|
|
30
|
+
throw Error('MCP verification binding mismatch');
|
|
31
|
+
if (
|
|
32
|
+
typeof report.ok !== 'boolean' ||
|
|
33
|
+
typeof report.verification?.ok !== 'boolean' ||
|
|
34
|
+
!Array.isArray(report.verification?.strategies) ||
|
|
35
|
+
!report.verification.strategies.length ||
|
|
36
|
+
typeof report.health?.status !== 'string' ||
|
|
37
|
+
report.verification.strategies.some(
|
|
38
|
+
(strategy: {
|
|
39
|
+
strategyName?: string;
|
|
40
|
+
strategyRevision?: string;
|
|
41
|
+
controlState?: string;
|
|
42
|
+
}) =>
|
|
43
|
+
!strategy.strategyName ||
|
|
44
|
+
!/^sr1:[a-f0-9]+$/.test(strategy.strategyRevision ?? '') ||
|
|
45
|
+
!['active', 'entries_paused'].includes(strategy.controlState ?? ''),
|
|
46
|
+
) ||
|
|
47
|
+
report.ok !== (report.verification.ok && report.health.status === 'healthy')
|
|
48
|
+
)
|
|
49
|
+
throw Error('MCP verification result invalid');
|
|
50
|
+
const manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
51
|
+
if (
|
|
52
|
+
!/^sha256:[a-f0-9]{64}$/.test(imageDigest) ||
|
|
53
|
+
report.projectSha !== manifest.projectSha ||
|
|
54
|
+
report.imageDigest !== imageDigest ||
|
|
55
|
+
report.runtimePackageManifest?.sha256 !== hash(manifestBytes) ||
|
|
56
|
+
report.runtimePackageManifest?.bytes !== manifestBytes.length ||
|
|
57
|
+
JSON.stringify(report.runtimePackageManifest.manifest) !==
|
|
58
|
+
JSON.stringify(manifest)
|
|
59
|
+
)
|
|
60
|
+
throw Error('MCP verification image or manifest mismatch');
|
|
61
|
+
if (
|
|
62
|
+
!Number.isSafeInteger(report.observedAt) ||
|
|
63
|
+
report.observedAt > now + 30000 ||
|
|
64
|
+
(report.deploymentVerifiedAt !== null &&
|
|
65
|
+
(!Number.isSafeInteger(report.deploymentVerifiedAt) ||
|
|
66
|
+
report.deploymentVerifiedAt > report.observedAt))
|
|
67
|
+
)
|
|
68
|
+
throw Error('MCP verification timestamp invalid');
|
|
69
|
+
return {
|
|
70
|
+
report,
|
|
71
|
+
payload: { encoding: 'base64', data: bytes.toString('base64') },
|
|
72
|
+
sha256: pointer.sha256,
|
|
73
|
+
bytes: pointer.bytes,
|
|
74
|
+
observedAt: now,
|
|
75
|
+
fresh: now - report.observedAt <= 10 * 60000,
|
|
76
|
+
source: 'deploy_owned_verification_bundle',
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const readVerification = async (projectRoot: string, binding: Binding) => {
|
|
81
|
+
const manifestBytes = await readFile(
|
|
82
|
+
path.join(projectRoot, 'runtime-package-manifest.json'),
|
|
83
|
+
);
|
|
84
|
+
const manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
85
|
+
if (
|
|
86
|
+
!/^[a-f0-9]{40}$/.test(manifest.projectSha) ||
|
|
87
|
+
!/^[\w-]+$/.test(binding.userName) ||
|
|
88
|
+
!/^[\w-]+$/.test(binding.deploymentId) ||
|
|
89
|
+
!/^dc1:[a-f0-9]+$/.test(binding.deploymentCompositionId)
|
|
90
|
+
)
|
|
91
|
+
throw Error('MCP verification identity invalid');
|
|
92
|
+
const root = await realpath(
|
|
93
|
+
path.join(projectRoot, 'data', 'runtime-verification'),
|
|
94
|
+
);
|
|
95
|
+
const dir = path.join(
|
|
96
|
+
root,
|
|
97
|
+
binding.userName,
|
|
98
|
+
binding.deploymentId,
|
|
99
|
+
manifest.projectSha,
|
|
100
|
+
binding.deploymentCompositionId,
|
|
101
|
+
);
|
|
102
|
+
const confined = async (name: string) => {
|
|
103
|
+
const resolved = await realpath(path.join(dir, name));
|
|
104
|
+
if (!resolved.startsWith(root + path.sep))
|
|
105
|
+
throw Error('MCP verification outside data root');
|
|
106
|
+
return resolved;
|
|
107
|
+
};
|
|
108
|
+
const pointerBytes = await readFile(await confined('latest.json'));
|
|
109
|
+
if (pointerBytes.length > 1024)
|
|
110
|
+
throw Error('MCP verification pointer too large');
|
|
111
|
+
const pointer = JSON.parse(pointerBytes.toString('utf8'));
|
|
112
|
+
if (
|
|
113
|
+
!/^[a-f0-9]{64}\.json$/.test(pointer.file) ||
|
|
114
|
+
pointer.file !== `${pointer.sha256}.json` ||
|
|
115
|
+
!Number.isSafeInteger(pointer.bytes) ||
|
|
116
|
+
pointer.bytes < 1 ||
|
|
117
|
+
pointer.bytes > 48000
|
|
118
|
+
)
|
|
119
|
+
throw Error('MCP verification pointer invalid');
|
|
120
|
+
const payloadPath = await confined(pointer.file);
|
|
121
|
+
if ((await stat(payloadPath)).size !== pointer.bytes)
|
|
122
|
+
throw Error('MCP verification size mismatch');
|
|
123
|
+
const bytes = await readFile(payloadPath);
|
|
124
|
+
const result = parseDeploymentVerification(
|
|
125
|
+
bytes,
|
|
126
|
+
pointer,
|
|
127
|
+
binding,
|
|
128
|
+
manifestBytes,
|
|
129
|
+
String(process.env.TRADEJS_PROJECT_IMAGE_DIGEST ?? ''),
|
|
130
|
+
);
|
|
131
|
+
const attemptPath = path.join(
|
|
132
|
+
root,
|
|
133
|
+
binding.userName,
|
|
134
|
+
binding.deploymentId,
|
|
135
|
+
'latest-attempt.json',
|
|
136
|
+
);
|
|
137
|
+
try {
|
|
138
|
+
const resolved = await realpath(attemptPath);
|
|
139
|
+
if (
|
|
140
|
+
!resolved.startsWith(root + path.sep) ||
|
|
141
|
+
(await stat(resolved)).size > 2048
|
|
142
|
+
)
|
|
143
|
+
throw Error('MCP verification attempt invalid');
|
|
144
|
+
const attempt = JSON.parse(await readFile(resolved, 'utf8'));
|
|
145
|
+
if (
|
|
146
|
+
attempt.projectSha !== manifest.projectSha ||
|
|
147
|
+
attempt.imageDigest !== result.report.imageDigest ||
|
|
148
|
+
!Number.isSafeInteger(attempt.observedAt) ||
|
|
149
|
+
!['running', 'failed', 'succeeded'].includes(attempt.status)
|
|
150
|
+
)
|
|
151
|
+
throw Error('MCP verification attempt binding invalid');
|
|
152
|
+
return {
|
|
153
|
+
...result,
|
|
154
|
+
fresh:
|
|
155
|
+
result.fresh &&
|
|
156
|
+
!(
|
|
157
|
+
attempt.status !== 'succeeded' &&
|
|
158
|
+
attempt.observedAt + 1000 >= result.report.observedAt
|
|
159
|
+
),
|
|
160
|
+
collection: attempt,
|
|
161
|
+
};
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const readDeploymentVerification = async (
|
|
169
|
+
projectRoot: string,
|
|
170
|
+
binding: Binding,
|
|
171
|
+
) => {
|
|
172
|
+
try {
|
|
173
|
+
return await readVerification(projectRoot, binding);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
176
|
+
throw new Error(
|
|
177
|
+
'MCP deployment verification is not published for the current image and composition',
|
|
178
|
+
);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
};
|