@aiwg/cli 2026.8.3 → 2026.8.5
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/dist/src/a2a/hitl-driver.js +28 -1
- package/dist/src/a2a/hitl.js +32 -8
- package/dist/src/audit/operator-decision.js +180 -0
- package/dist/src/cli/handlers/mc.js +270 -73
- package/dist/src/extensions/claude-hooks-installer.js +9 -5
- package/dist/src/mcp/cli.mjs +12 -0
- package/dist/src/mcp/registry.js +14 -1
- package/dist/src/mcp/registry.mjs +15 -1
- package/dist/src/research/query-cli.js +94 -24
- package/dist/src/resources/web-release.d.ts +7 -0
- package/dist/src/resources/web-release.js +149 -17
- package/dist/src/serve/shared-host-scheduler.js +260 -0
- package/dist/src/storage/backends/fortemi.js +95 -13
- package/dist/src/storage/cli.js +93 -6
- package/package.json +1 -1
- package/tools/plugin/package-plugins.mjs +170 -15
|
@@ -60,6 +60,17 @@ const DEFAULT_REGISTRY = {
|
|
|
60
60
|
servers: {},
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
const ENV_REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
64
|
+
|
|
65
|
+
function validateCredentialReferences(def) {
|
|
66
|
+
for (const [header, envName] of Object.entries(def.headerEnv || {})) {
|
|
67
|
+
if (!header.trim()) throw new Error('MCP header-env header name must not be empty');
|
|
68
|
+
if (!ENV_REFERENCE_NAME.test(envName)) {
|
|
69
|
+
throw new Error(`Invalid MCP header environment variable reference "${envName}"`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
export class McpServerRegistry {
|
|
64
75
|
#configDir;
|
|
65
76
|
#cache = null;
|
|
@@ -99,6 +110,7 @@ export class McpServerRegistry {
|
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
async add(def) {
|
|
113
|
+
validateCredentialReferences(def);
|
|
102
114
|
const data = await this.load();
|
|
103
115
|
|
|
104
116
|
if (data.servers[def.name]) {
|
|
@@ -133,12 +145,14 @@ export class McpServerRegistry {
|
|
|
133
145
|
throw new Error(`Server "${name}" not found.`);
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
|
|
148
|
+
const next = {
|
|
137
149
|
...data.servers[name],
|
|
138
150
|
...updates,
|
|
139
151
|
name,
|
|
140
152
|
updatedAt: new Date().toISOString(),
|
|
141
153
|
};
|
|
154
|
+
validateCredentialReferences(next);
|
|
155
|
+
data.servers[name] = next;
|
|
142
156
|
|
|
143
157
|
await this.save();
|
|
144
158
|
}
|
|
@@ -40,7 +40,7 @@ function usage() {
|
|
|
40
40
|
return [
|
|
41
41
|
"Usage: aiwg research-query <question> [--backend fortemi-core|local] [--graph <name>]",
|
|
42
42
|
" [--depth quick|thorough] [--sources-only] [--max-sources N]",
|
|
43
|
-
" [--json] [--save]",
|
|
43
|
+
" [--include-diagnostics] [--json] [--save]",
|
|
44
44
|
].join("\n");
|
|
45
45
|
}
|
|
46
46
|
function flagValue(args, flag, errorMessage) {
|
|
@@ -73,7 +73,12 @@ function stripFlags(args) {
|
|
|
73
73
|
"--depth",
|
|
74
74
|
"--max-sources",
|
|
75
75
|
]);
|
|
76
|
-
const bareFlags = new Set([
|
|
76
|
+
const bareFlags = new Set([
|
|
77
|
+
"--sources-only",
|
|
78
|
+
"--include-diagnostics",
|
|
79
|
+
"--json",
|
|
80
|
+
"--save",
|
|
81
|
+
]);
|
|
77
82
|
const question = [];
|
|
78
83
|
for (let index = 0; index < args.length; index++) {
|
|
79
84
|
const arg = args[index];
|
|
@@ -96,8 +101,7 @@ function parseArgs(args) {
|
|
|
96
101
|
if (backend && backend !== "local" && backend !== "fortemi-core") {
|
|
97
102
|
throw new Error("--backend must be local or fortemi-core");
|
|
98
103
|
}
|
|
99
|
-
const depth = (flagValue(args, "--depth", "--depth must be quick or thorough") ??
|
|
100
|
-
"thorough");
|
|
104
|
+
const depth = (flagValue(args, "--depth", "--depth must be quick or thorough") ?? "thorough");
|
|
101
105
|
if (depth !== "quick" && depth !== "thorough") {
|
|
102
106
|
throw new Error("--depth must be quick or thorough");
|
|
103
107
|
}
|
|
@@ -113,6 +117,7 @@ function parseArgs(args) {
|
|
|
113
117
|
depth,
|
|
114
118
|
maxSources,
|
|
115
119
|
sourcesOnly: hasFlag(args, "--sources-only"),
|
|
120
|
+
includeDiagnostics: hasFlag(args, "--include-diagnostics"),
|
|
116
121
|
json: hasFlag(args, "--json"),
|
|
117
122
|
save: hasFlag(args, "--save"),
|
|
118
123
|
};
|
|
@@ -140,18 +145,40 @@ function entryId(entry) {
|
|
|
140
145
|
text.match(/\bPROF-[A-Z0-9-]+/i)?.[0]?.toUpperCase() ??
|
|
141
146
|
path.basename(entry.path).replace(/\.[^.]+$/, ""));
|
|
142
147
|
}
|
|
143
|
-
function
|
|
144
|
-
const normalized =
|
|
145
|
-
if (
|
|
148
|
+
function normalizedGrade(value) {
|
|
149
|
+
const normalized = value.trim().toUpperCase();
|
|
150
|
+
if (/^(VERY\s+LOW|D)$/.test(normalized))
|
|
146
151
|
return "VERY LOW";
|
|
147
|
-
if (
|
|
152
|
+
if (/^(HIGH|A(?:-)?)$/.test(normalized))
|
|
148
153
|
return "HIGH";
|
|
149
|
-
if (
|
|
154
|
+
if (/^(MODERATE|B(?:-)?)$/.test(normalized))
|
|
150
155
|
return "MODERATE";
|
|
151
|
-
if (
|
|
156
|
+
if (/^(LOW|C(?:-)?)$/.test(normalized))
|
|
152
157
|
return "LOW";
|
|
153
158
|
return "UNKNOWN";
|
|
154
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Extract only an explicitly declared research grade. Generic severity words
|
|
162
|
+
* in scan reports must never become evidence-quality signals.
|
|
163
|
+
*/
|
|
164
|
+
function gradeForEntry(entry, body) {
|
|
165
|
+
for (const tag of entry.tags) {
|
|
166
|
+
const tagged = tag.match(/^grade[-_: ](very[-_ ]low|high|moderate|low|[a-d](?:-)?)$/i);
|
|
167
|
+
if (tagged)
|
|
168
|
+
return normalizedGrade(tagged[1].replace(/[-_]/g, " "));
|
|
169
|
+
}
|
|
170
|
+
const text = [entry.summary, body].join("\n");
|
|
171
|
+
const declarations = [
|
|
172
|
+
/\bGRADE(?:\s+(?:quality|rating|level|assessment))?\s*[:=]\s*\*{0,2}\s*(VERY\s+LOW|HIGH|MODERATE|LOW|[A-D](?:-)?)\b/i,
|
|
173
|
+
/##\s+GRADE[^\n]*\n(?:[^\n]*\n){0,3}?[^\n]*?(?:overall|rating|level)?\s*[:=]\s*\*{0,2}\s*(VERY\s+LOW|HIGH|MODERATE|LOW|[A-D](?:-)?)\b/i,
|
|
174
|
+
];
|
|
175
|
+
for (const declaration of declarations) {
|
|
176
|
+
const match = declaration.exec(text);
|
|
177
|
+
if (match)
|
|
178
|
+
return normalizedGrade(match[1]);
|
|
179
|
+
}
|
|
180
|
+
return "UNKNOWN";
|
|
181
|
+
}
|
|
155
182
|
function relevance(score) {
|
|
156
183
|
if (score >= 0.55)
|
|
157
184
|
return "direct";
|
|
@@ -191,26 +218,58 @@ function isResearchEntry(entry) {
|
|
|
191
218
|
entryPath.includes("/research/") ||
|
|
192
219
|
entryPath.includes("/kb/"));
|
|
193
220
|
}
|
|
221
|
+
/** Generated diagnostics are searchable only by explicit operator opt-in. */
|
|
222
|
+
function isDiagnosticEntry(entry) {
|
|
223
|
+
const type = entry.type.toLowerCase();
|
|
224
|
+
const entryPath = entry.path.toLowerCase().replaceAll("\\", "/");
|
|
225
|
+
const tags = entry.tags.map((tag) => tag.toLowerCase());
|
|
226
|
+
return (entryPath.includes("/.aiwg/research/quarantine/") ||
|
|
227
|
+
entryPath.includes("/research/quarantine/") ||
|
|
228
|
+
/(?:^|\/)no-ref-[^/]*artifact-scan\.md$/.test(entryPath) ||
|
|
229
|
+
/(?:llm|integrity|artifact)-scan\.md$/.test(entryPath) ||
|
|
230
|
+
/(?:integrity|artifact|llm)[-_ ]scan|quarantine|diagnostic/.test(type) ||
|
|
231
|
+
tags.some((tag) => /^(?:integrity|artifact|llm)[-_ ]scan$|^quarantine$|^diagnostic$/.test(tag)));
|
|
232
|
+
}
|
|
194
233
|
function localEntries(cwd, graph) {
|
|
195
234
|
const index = loadGraphIndexFile(cwd, "metadata.json", graph);
|
|
196
|
-
return index ? Object.values(index.entries) : [];
|
|
235
|
+
return index ? Object.values(index.entries).map((entry) => ({ entry })) : [];
|
|
236
|
+
}
|
|
237
|
+
function cachedRecordBody(record) {
|
|
238
|
+
return [
|
|
239
|
+
record.search?.body,
|
|
240
|
+
record.text,
|
|
241
|
+
...(record.chunks ?? []).map((chunk) => chunk.text),
|
|
242
|
+
]
|
|
243
|
+
.filter((part) => Boolean(part))
|
|
244
|
+
.join("\n");
|
|
197
245
|
}
|
|
198
246
|
async function backendEntries(cwd, graph, backend) {
|
|
199
247
|
if (backend === "fortemi-core") {
|
|
200
|
-
const { loadFortemiCoreMetadataEntries } = await import("../artifacts/fortemi-core-query-adapter.js");
|
|
248
|
+
const { loadFortemiCoreExport, loadFortemiCoreMetadataEntries } = await import("../artifacts/fortemi-core-query-adapter.js");
|
|
201
249
|
const loaded = loadFortemiCoreMetadataEntries(cwd, graph);
|
|
202
|
-
|
|
250
|
+
if (loaded.reason)
|
|
251
|
+
return { entries: [], hint: loaded.reason };
|
|
252
|
+
const exported = loadFortemiCoreExport(cwd, graph);
|
|
253
|
+
if (!exported.exported)
|
|
254
|
+
return { entries: [], hint: exported.reason };
|
|
255
|
+
const records = new Map(exported.exported.items.map((record) => [record.source.path, record]));
|
|
256
|
+
return {
|
|
257
|
+
entries: loaded.entries.map((entry) => {
|
|
258
|
+
const record = records.get(entry.path);
|
|
259
|
+
return {
|
|
260
|
+
entry,
|
|
261
|
+
cachedBody: record ? cachedRecordBody(record) : "",
|
|
262
|
+
};
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
203
265
|
}
|
|
204
266
|
return { entries: localEntries(cwd, graph) };
|
|
205
267
|
}
|
|
206
|
-
function sourceForEntry(cwd,
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
entry.
|
|
210
|
-
|
|
211
|
-
entry.tags.join(" "),
|
|
212
|
-
body,
|
|
213
|
-
].join("\n");
|
|
268
|
+
function sourceForEntry(cwd, candidate, question, depth) {
|
|
269
|
+
const { entry } = candidate;
|
|
270
|
+
const body = depth === "thorough"
|
|
271
|
+
? (candidate.cachedBody ?? readEntryBody(cwd, entry.path))
|
|
272
|
+
: "";
|
|
214
273
|
const score = scoreEntry(entry, question, body, depth);
|
|
215
274
|
if (score <= 0)
|
|
216
275
|
return null;
|
|
@@ -219,7 +278,7 @@ function sourceForEntry(cwd, entry, question, depth) {
|
|
|
219
278
|
path: entry.path,
|
|
220
279
|
title: entry.title,
|
|
221
280
|
type: entry.type,
|
|
222
|
-
grade:
|
|
281
|
+
grade: gradeForEntry(entry, body),
|
|
223
282
|
relevance: relevance(score),
|
|
224
283
|
score: Math.round(score * 1000) / 1000,
|
|
225
284
|
summary: entry.summary,
|
|
@@ -236,13 +295,24 @@ export async function runResearchQuery(cwd, options) {
|
|
|
236
295
|
throw new Error(loaded.hint);
|
|
237
296
|
}
|
|
238
297
|
const sources = loaded.entries
|
|
239
|
-
.filter(isResearchEntry)
|
|
240
|
-
.
|
|
298
|
+
.filter(({ entry }) => isResearchEntry(entry))
|
|
299
|
+
.filter(({ entry }) => options.includeDiagnostics || !isDiagnosticEntry(entry))
|
|
300
|
+
.map((candidate) => sourceForEntry(cwd, candidate, options.question, depth))
|
|
241
301
|
.filter((source) => source !== null)
|
|
242
302
|
.sort((left, right) => {
|
|
243
303
|
const scoreCmp = right.score - left.score;
|
|
244
304
|
if (scoreCmp !== 0)
|
|
245
305
|
return scoreCmp;
|
|
306
|
+
const gradeOrder = [
|
|
307
|
+
"HIGH",
|
|
308
|
+
"MODERATE",
|
|
309
|
+
"LOW",
|
|
310
|
+
"VERY LOW",
|
|
311
|
+
"UNKNOWN",
|
|
312
|
+
];
|
|
313
|
+
const gradeCmp = gradeOrder.indexOf(left.grade) - gradeOrder.indexOf(right.grade);
|
|
314
|
+
if (gradeCmp !== 0)
|
|
315
|
+
return gradeCmp;
|
|
246
316
|
return left.path.localeCompare(right.path);
|
|
247
317
|
})
|
|
248
318
|
.slice(0, maxSources);
|
|
@@ -49,6 +49,13 @@ export interface WebReleaseOptions {
|
|
|
49
49
|
credentialProvider?: () => Promise<string | null>;
|
|
50
50
|
/** Test/development escape hatch. HTTP remains restricted to loopback. */
|
|
51
51
|
allowInsecureLoopbackHttp?: boolean;
|
|
52
|
+
/** Structured cache diagnostics; never includes URLs, headers, or credentials. */
|
|
53
|
+
onDiagnostic?: (diagnostic: WebReleaseDiagnostic) => void;
|
|
54
|
+
}
|
|
55
|
+
export interface WebReleaseDiagnostic {
|
|
56
|
+
resource: "channel" | "version-index";
|
|
57
|
+
outcome: "conditional-hit" | "revalidated" | "unconditional";
|
|
58
|
+
validator: "etag" | "last-modified" | "none";
|
|
52
59
|
}
|
|
53
60
|
export interface VerifiedReleaseDescriptor {
|
|
54
61
|
path: string;
|
|
@@ -386,6 +386,69 @@ async function fetchBytes(fetcher, url, label, maxBytes, bearerToken) {
|
|
|
386
386
|
clearTimeout(timeout);
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
|
+
function validatorFromResponse(response, payloadSha256) {
|
|
390
|
+
// Preserve the origin's ETag octets, including the W/ prefix for weak tags.
|
|
391
|
+
// HTTP validators only suppress transfer; Ed25519 and SHA-256 remain the
|
|
392
|
+
// authority for every cached representation accepted by this module.
|
|
393
|
+
const etag = response.headers.get("etag")?.trim();
|
|
394
|
+
const lastModified = response.headers.get("last-modified")?.trim();
|
|
395
|
+
if (!etag && !lastModified)
|
|
396
|
+
return undefined;
|
|
397
|
+
return {
|
|
398
|
+
schemaVersion: "aiwg.http-validator/v1",
|
|
399
|
+
payloadSha256,
|
|
400
|
+
...(etag ? { etag } : { lastModified: lastModified }),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function readMetadataValidator(pathname, payloadSha256) {
|
|
404
|
+
if (!fs.existsSync(pathname))
|
|
405
|
+
return undefined;
|
|
406
|
+
const value = parseJson(readVerifiedRegularFile(pathname, {
|
|
407
|
+
label: "cached HTTP metadata validator",
|
|
408
|
+
maxBytes: MAX_COMPLETION_MARKER_BYTES,
|
|
409
|
+
}), "cached HTTP metadata validator");
|
|
410
|
+
if (!isRecord(value) || value.schemaVersion !== "aiwg.http-validator/v1" || value.payloadSha256 !== payloadSha256) {
|
|
411
|
+
throw new Error("cached HTTP metadata validator does not match the verified signed payload");
|
|
412
|
+
}
|
|
413
|
+
const etag = typeof value.etag === "string" && value.etag.trim() ? value.etag.trim() : undefined;
|
|
414
|
+
const lastModified = typeof value.lastModified === "string" && value.lastModified.trim() ? value.lastModified.trim() : undefined;
|
|
415
|
+
if (!etag && !lastModified)
|
|
416
|
+
throw new Error("cached HTTP metadata validator is empty");
|
|
417
|
+
return { schemaVersion: "aiwg.http-validator/v1", payloadSha256, ...(etag ? { etag } : { lastModified }) };
|
|
418
|
+
}
|
|
419
|
+
async function fetchMetadata(fetcher, url, label, maxBytes, cachedValidator) {
|
|
420
|
+
const headers = { "accept-encoding": "identity" };
|
|
421
|
+
if (cachedValidator?.etag)
|
|
422
|
+
headers["if-none-match"] = cachedValidator.etag;
|
|
423
|
+
else if (cachedValidator?.lastModified)
|
|
424
|
+
headers["if-modified-since"] = cachedValidator.lastModified;
|
|
425
|
+
const controller = new AbortController();
|
|
426
|
+
const timeout = setTimeout(() => controller.abort(), RESOURCE_FETCH_TIMEOUT_MS);
|
|
427
|
+
let response;
|
|
428
|
+
try {
|
|
429
|
+
response = await fetcher(url, { redirect: "error", headers, signal: controller.signal });
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (controller.signal.aborted)
|
|
433
|
+
throw new Error(`${label} request timed out after ${RESOURCE_FETCH_TIMEOUT_MS}ms`);
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
clearTimeout(timeout);
|
|
438
|
+
}
|
|
439
|
+
if (response.status === 304) {
|
|
440
|
+
if (!cachedValidator)
|
|
441
|
+
throw new Error(`${label} returned 304 without a verified cached representation`);
|
|
442
|
+
return {
|
|
443
|
+
notModified: true,
|
|
444
|
+
validator: validatorFromResponse(response, cachedValidator.payloadSha256) ?? cachedValidator,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (!response.ok)
|
|
448
|
+
throw new Error(`${label} fetch failed (${response.status}): ${url}`);
|
|
449
|
+
const bytes = await fetchBytes(async () => response, url, label, maxBytes);
|
|
450
|
+
return { notModified: false, bytes, validator: validatorFromResponse(response, sha256(bytes)) };
|
|
451
|
+
}
|
|
389
452
|
function verifyDescriptor(bytes, descriptor, label = descriptor.path) {
|
|
390
453
|
if (bytes.byteLength !== descriptor.size || sha256(bytes) !== descriptor.sha256) {
|
|
391
454
|
throw new Error(`release descriptor size or digest verification failed: ${label}`);
|
|
@@ -623,7 +686,14 @@ function readCachedChannel(cacheRoot, channel, publicKeyPem) {
|
|
|
623
686
|
if (candidate.name !== `${manifest.sequence}-${digest}`) {
|
|
624
687
|
throw new Error(`cached channel ${channel} generation name does not match its signed metadata`);
|
|
625
688
|
}
|
|
626
|
-
|
|
689
|
+
let validator;
|
|
690
|
+
try {
|
|
691
|
+
validator = readMetadataValidator(path.join(dir, "http-validator.json"), digest);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
validator = undefined;
|
|
695
|
+
}
|
|
696
|
+
valid.push({ manifest, bytes, signatureBytes, digest, validator });
|
|
627
697
|
}
|
|
628
698
|
catch {
|
|
629
699
|
corrupt = true;
|
|
@@ -658,10 +728,23 @@ function readCachedVersionIndex(cacheRoot, publicKeyPem) {
|
|
|
658
728
|
label: "cached resource version index signature",
|
|
659
729
|
maxBytes: MAX_SIGNATURE_BYTES,
|
|
660
730
|
});
|
|
661
|
-
verifySignedResourceBytes(bytes, signatureBytes, publicKeyPem, "cached resource version index");
|
|
662
|
-
return
|
|
731
|
+
const digest = verifySignedResourceBytes(bytes, signatureBytes, publicKeyPem, "cached resource version index");
|
|
732
|
+
return {
|
|
733
|
+
index: validateVersionIndex(parseJson(bytes, "cached resource version index")),
|
|
734
|
+
bytes,
|
|
735
|
+
signatureBytes,
|
|
736
|
+
digest,
|
|
737
|
+
validator: (() => {
|
|
738
|
+
try {
|
|
739
|
+
return readMetadataValidator(path.join(dir, "http-validator.json"), digest);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
})(),
|
|
745
|
+
};
|
|
663
746
|
}
|
|
664
|
-
function cacheVersionIndex(cacheRoot, bytes, signatureBytes) {
|
|
747
|
+
function cacheVersionIndex(cacheRoot, bytes, signatureBytes, validator) {
|
|
665
748
|
const target = versionIndexCacheDir(cacheRoot);
|
|
666
749
|
const stagingRoot = path.join(cacheRoot, ".staging", "versions");
|
|
667
750
|
fs.mkdirSync(stagingRoot, { recursive: true });
|
|
@@ -669,6 +752,8 @@ function cacheVersionIndex(cacheRoot, bytes, signatureBytes) {
|
|
|
669
752
|
try {
|
|
670
753
|
fs.writeFileSync(path.join(stage, "versions.json"), bytes, { flag: "wx" });
|
|
671
754
|
fs.writeFileSync(path.join(stage, "versions.sig"), signatureBytes, { flag: "wx" });
|
|
755
|
+
if (validator)
|
|
756
|
+
fs.writeFileSync(path.join(stage, "http-validator.json"), `${JSON.stringify(validator)}\n`, { flag: "wx" });
|
|
672
757
|
if (fs.existsSync(target))
|
|
673
758
|
fs.rmSync(target, { recursive: true, force: true });
|
|
674
759
|
installGeneration(stage, target);
|
|
@@ -678,19 +763,33 @@ function cacheVersionIndex(cacheRoot, bytes, signatureBytes) {
|
|
|
678
763
|
throw error;
|
|
679
764
|
}
|
|
680
765
|
}
|
|
681
|
-
async function fetchAndCacheVersionIndex(base, fetcher, cacheRoot, publicKeyPem) {
|
|
682
|
-
|
|
766
|
+
async function fetchAndCacheVersionIndex(base, fetcher, cacheRoot, publicKeyPem, diagnostic) {
|
|
767
|
+
let cached = null;
|
|
768
|
+
try {
|
|
769
|
+
cached = readCachedVersionIndex(cacheRoot, publicKeyPem);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
cached = null;
|
|
773
|
+
}
|
|
774
|
+
const fetched = await fetchMetadata(fetcher, resourceUrl(base, "resources/versions.json"), "resource version index", MAX_VERSION_INDEX_BYTES, cached?.validator);
|
|
775
|
+
if (fetched.notModified) {
|
|
776
|
+
cacheVersionIndex(cacheRoot, cached.bytes, cached.signatureBytes, fetched.validator);
|
|
777
|
+
diagnostic?.({ resource: "version-index", outcome: "conditional-hit", validator: cached.validator.etag ? "etag" : "last-modified" });
|
|
778
|
+
return cached.index;
|
|
779
|
+
}
|
|
780
|
+
const indexBytes = fetched.bytes;
|
|
683
781
|
const signatureBytes = await fetchBytes(fetcher, resourceUrl(base, "resources/versions.sig"), "resource version index signature", MAX_SIGNATURE_BYTES);
|
|
684
782
|
verifySignedResourceBytes(indexBytes, signatureBytes, publicKeyPem, "resource version index");
|
|
685
783
|
const index = validateVersionIndex(parseJson(indexBytes, "resource version index"));
|
|
686
|
-
cacheVersionIndex(cacheRoot, indexBytes, signatureBytes);
|
|
784
|
+
cacheVersionIndex(cacheRoot, indexBytes, signatureBytes, fetched.validator);
|
|
785
|
+
diagnostic?.({ resource: "version-index", outcome: cached?.validator ? "revalidated" : "unconditional", validator: fetched.validator?.etag ? "etag" : fetched.validator?.lastModified ? "last-modified" : "none" });
|
|
687
786
|
return index;
|
|
688
787
|
}
|
|
689
|
-
async function resolveVersionIndex(base, fetcher, cacheRoot, publicKeyPem, offline) {
|
|
788
|
+
async function resolveVersionIndex(base, fetcher, cacheRoot, publicKeyPem, offline, diagnostic) {
|
|
690
789
|
if (offline) {
|
|
691
790
|
const cached = readCachedVersionIndex(cacheRoot, publicKeyPem);
|
|
692
791
|
if (cached)
|
|
693
|
-
return cached;
|
|
792
|
+
return cached.index;
|
|
694
793
|
const versions = cachedReleaseVersions(cacheRoot).flatMap((version) => cachedDigests(cacheRoot, version).map((digest) => ({
|
|
695
794
|
version,
|
|
696
795
|
releaseManifest: `/resources/${version}/manifest.json`,
|
|
@@ -703,12 +802,14 @@ async function resolveVersionIndex(base, fetcher, cacheRoot, publicKeyPem, offli
|
|
|
703
802
|
if (!fetcher)
|
|
704
803
|
throw new Error("No fetch implementation is available for AIWG web resources");
|
|
705
804
|
try {
|
|
706
|
-
return await fetchAndCacheVersionIndex(base, fetcher, cacheRoot, publicKeyPem);
|
|
805
|
+
return await fetchAndCacheVersionIndex(base, fetcher, cacheRoot, publicKeyPem, diagnostic);
|
|
707
806
|
}
|
|
708
807
|
catch (error) {
|
|
808
|
+
if (error instanceof Error && /fetch failed \((?:401|403|429|5\d\d)\)/.test(error.message))
|
|
809
|
+
throw error;
|
|
709
810
|
const cached = readCachedVersionIndex(cacheRoot, publicKeyPem);
|
|
710
811
|
if (cached)
|
|
711
|
-
return cached;
|
|
812
|
+
return cached.index;
|
|
712
813
|
throw error;
|
|
713
814
|
}
|
|
714
815
|
}
|
|
@@ -776,7 +877,7 @@ function fsyncTree(root) {
|
|
|
776
877
|
}
|
|
777
878
|
fsyncDirectory(root);
|
|
778
879
|
}
|
|
779
|
-
function cacheChannel(cacheRoot, manifest, bytes, signatureBytes, digest) {
|
|
880
|
+
function cacheChannel(cacheRoot, manifest, bytes, signatureBytes, digest, validator) {
|
|
780
881
|
const root = channelGenerationRoot(cacheRoot, manifest.channel);
|
|
781
882
|
const stagingRoot = path.join(cacheRoot, ".staging", "channels");
|
|
782
883
|
fs.mkdirSync(stagingRoot, { recursive: true });
|
|
@@ -784,6 +885,8 @@ function cacheChannel(cacheRoot, manifest, bytes, signatureBytes, digest) {
|
|
|
784
885
|
try {
|
|
785
886
|
fs.writeFileSync(path.join(stage, "channel.json"), bytes, { flag: "wx" });
|
|
786
887
|
fs.writeFileSync(path.join(stage, "channel.sig"), signatureBytes, { flag: "wx" });
|
|
888
|
+
if (validator)
|
|
889
|
+
fs.writeFileSync(path.join(stage, "http-validator.json"), `${JSON.stringify(validator)}\n`, { flag: "wx" });
|
|
787
890
|
const target = path.join(root, `${manifest.sequence}-${digest}`);
|
|
788
891
|
if (fs.existsSync(target)) {
|
|
789
892
|
let matches = false;
|
|
@@ -797,7 +900,11 @@ function cacheChannel(cacheRoot, manifest, bytes, signatureBytes, digest) {
|
|
|
797
900
|
readVerifiedRegularFile(path.join(target, "channel.sig"), {
|
|
798
901
|
label: `cached channel ${manifest.channel} signature`,
|
|
799
902
|
maxBytes: MAX_SIGNATURE_BYTES,
|
|
800
|
-
}).equals(Buffer.from(signatureBytes))
|
|
903
|
+
}).equals(Buffer.from(signatureBytes)) &&
|
|
904
|
+
(validator
|
|
905
|
+
? readMetadataValidator(path.join(target, "http-validator.json"), digest)?.etag === validator.etag &&
|
|
906
|
+
readMetadataValidator(path.join(target, "http-validator.json"), digest)?.lastModified === validator.lastModified
|
|
907
|
+
: !fs.existsSync(path.join(target, "http-validator.json")));
|
|
801
908
|
}
|
|
802
909
|
catch {
|
|
803
910
|
matches = false;
|
|
@@ -921,8 +1028,20 @@ export async function resolveWebRelease(options = {}) {
|
|
|
921
1028
|
return fetchAndCacheRelease(base, fetcher, cacheRoot, selector, selector.value, publicKeyPem);
|
|
922
1029
|
}
|
|
923
1030
|
if (selector.kind === "range" || selector.kind === "digest") {
|
|
1031
|
+
if (!options.offline && selector.kind === "digest") {
|
|
1032
|
+
for (const version of cachedReleaseVersions(cacheRoot)) {
|
|
1033
|
+
if (!cachedDigests(cacheRoot, version).includes(selector.digest))
|
|
1034
|
+
continue;
|
|
1035
|
+
try {
|
|
1036
|
+
return verifyCachedGeneration(cacheRoot, version, selector.digest, selector, publicKeyPem, base, selector.digest);
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
// A corrupt immutable generation cannot bypass signed index resolution.
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
924
1043
|
const fetcher = authorize;
|
|
925
|
-
const index = await resolveVersionIndex(base, fetcher, cacheRoot, publicKeyPem, options.offline);
|
|
1044
|
+
const index = await resolveVersionIndex(base, fetcher, cacheRoot, publicKeyPem, options.offline, options.onDiagnostic);
|
|
926
1045
|
const selected = selectVersionFromIndex(index, selector);
|
|
927
1046
|
if (options.offline) {
|
|
928
1047
|
return resolveOfflineExact(cacheRoot, selector, selected.version, publicKeyPem, base, selected.releaseManifestSha256);
|
|
@@ -941,11 +1060,23 @@ export async function resolveWebRelease(options = {}) {
|
|
|
941
1060
|
if (!fetcher)
|
|
942
1061
|
throw new Error("No fetch implementation is available for AIWG web resources");
|
|
943
1062
|
const channelPrefix = `resources/channels/${selector.value}`;
|
|
944
|
-
|
|
1063
|
+
let prior = null;
|
|
1064
|
+
try {
|
|
1065
|
+
prior = readCachedChannel(cacheRoot, selector.value, publicKeyPem);
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
prior = null;
|
|
1069
|
+
}
|
|
1070
|
+
const fetched = await fetchMetadata(fetcher, resourceUrl(base, `${channelPrefix}.json`), `channel ${selector.value}`, MAX_SIGNED_METADATA_BYTES, prior?.validator);
|
|
1071
|
+
if (fetched.notModified) {
|
|
1072
|
+
cacheChannel(cacheRoot, prior.manifest, prior.bytes, prior.signatureBytes, prior.digest, fetched.validator);
|
|
1073
|
+
options.onDiagnostic?.({ resource: "channel", outcome: "conditional-hit", validator: prior.validator.etag ? "etag" : "last-modified" });
|
|
1074
|
+
return fetchAndCacheRelease(base, fetcher, cacheRoot, selector, prior.manifest.version, publicKeyPem, prior.manifest.releaseManifestSha256, prior.manifest.sequence);
|
|
1075
|
+
}
|
|
1076
|
+
const channelBytes = fetched.bytes;
|
|
945
1077
|
const channelSignatureBytes = await fetchBytes(fetcher, resourceUrl(base, `${channelPrefix}.sig`), `channel ${selector.value} signature`, MAX_SIGNATURE_BYTES);
|
|
946
1078
|
const channelDigest = verifySignedResourceBytes(channelBytes, channelSignatureBytes, publicKeyPem, `channel ${selector.value}`);
|
|
947
1079
|
const channel = validateChannelManifest(parseJson(channelBytes, `channel ${selector.value}`), selector.value);
|
|
948
|
-
const prior = readCachedChannel(cacheRoot, selector.value, publicKeyPem);
|
|
949
1080
|
if (prior && channel.sequence < prior.manifest.sequence) {
|
|
950
1081
|
throw new Error(`channel ${selector.value} sequence rollback detected (${channel.sequence} < ${prior.manifest.sequence})`);
|
|
951
1082
|
}
|
|
@@ -957,7 +1088,8 @@ export async function resolveWebRelease(options = {}) {
|
|
|
957
1088
|
throw new Error(`channel ${selector.value} sequence ${channel.sequence} has conflicting signed metadata`);
|
|
958
1089
|
}
|
|
959
1090
|
const release = await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, channel.version, publicKeyPem, channel.releaseManifestSha256, channel.sequence);
|
|
960
|
-
cacheChannel(cacheRoot, channel, channelBytes, channelSignatureBytes, channelDigest);
|
|
1091
|
+
cacheChannel(cacheRoot, channel, channelBytes, channelSignatureBytes, channelDigest, fetched.validator);
|
|
1092
|
+
options.onDiagnostic?.({ resource: "channel", outcome: prior?.validator ? "revalidated" : "unconditional", validator: fetched.validator?.etag ? "etag" : fetched.validator?.lastModified ? "last-modified" : "none" });
|
|
961
1093
|
return release;
|
|
962
1094
|
}
|
|
963
1095
|
export async function fetchVerifiedRawResource(release, resourcePath, options = {}) {
|