@aiwg/cli 2026.8.4 → 2026.8.7
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/artifacts/move.js +15 -5
- package/dist/src/cli/handlers/artifacts.js +8 -3
- package/dist/src/mcp/cli.mjs +5 -2
- package/dist/src/mcp/server.mjs +1 -1
- package/dist/src/mcp/tools/agentic-sandbox.mjs +232 -0
- package/dist/src/mcp/tools/subsystems.mjs +2 -0
- package/dist/src/resources/web-release.d.ts +7 -0
- package/dist/src/resources/web-release.js +149 -17
- package/package.json +1 -1
|
@@ -108,21 +108,30 @@ export async function moveProjectArtifacts(options) {
|
|
|
108
108
|
const source = path.resolve(options.from ? expandProjectArtifactPath(options.from, projectDir) : resolveProjectAiwgDir(projectDir));
|
|
109
109
|
const destination = path.resolve(expandProjectArtifactPath(options.to, projectDir));
|
|
110
110
|
const dryRun = options.dryRun === true;
|
|
111
|
+
const attach = options.attach === true;
|
|
111
112
|
const reindex = options.reindex !== false;
|
|
112
113
|
const syncFortemi = options.syncFortemi !== false;
|
|
113
|
-
if (samePath(source, destination)) {
|
|
114
|
+
if (!attach && samePath(source, destination)) {
|
|
114
115
|
throw new Error(`Source and destination are the same directory: ${source}`);
|
|
115
116
|
}
|
|
116
|
-
if (!existsSync(source)) {
|
|
117
|
+
if (!attach && !existsSync(source)) {
|
|
117
118
|
throw new Error(`Source AIWG artifact directory does not exist: ${source}`);
|
|
118
119
|
}
|
|
119
|
-
if (
|
|
120
|
+
if (attach) {
|
|
121
|
+
if (!existsSync(destination) || !(await stat(destination)).isDirectory()) {
|
|
122
|
+
throw new Error(`Artifact root to attach does not exist or is not a directory: ${destination}`);
|
|
123
|
+
}
|
|
124
|
+
if (!existsSync(path.join(destination, 'aiwg.config'))) {
|
|
125
|
+
throw new Error(`Artifact root to attach has no aiwg.config: ${destination}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (existsSync(destination) && !(await isEmptyDirectory(destination))) {
|
|
120
129
|
throw new Error(`Destination already exists and is not empty: ${destination}`);
|
|
121
130
|
}
|
|
122
131
|
const pointerValue = pointerValueFor(projectDir, destination);
|
|
123
132
|
const pointerPath = path.join(projectDir, PROJECT_AIWG_LOCATION_FILE);
|
|
124
133
|
const gitignoreUpdated = await ensureGitignorePointer(projectDir, dryRun);
|
|
125
|
-
if (!dryRun) {
|
|
134
|
+
if (!dryRun && !attach) {
|
|
126
135
|
await moveDirectory(source, destination);
|
|
127
136
|
}
|
|
128
137
|
await writePointer(projectDir, pointerValue, dryRun);
|
|
@@ -144,7 +153,8 @@ export async function moveProjectArtifacts(options) {
|
|
|
144
153
|
to: destination,
|
|
145
154
|
pointerPath,
|
|
146
155
|
pointerValue,
|
|
147
|
-
moved: !dryRun,
|
|
156
|
+
moved: !dryRun && !attach,
|
|
157
|
+
attached: attach && !dryRun,
|
|
148
158
|
gitignoreUpdated,
|
|
149
159
|
reindexed,
|
|
150
160
|
fortemiSynced,
|
|
@@ -5,9 +5,11 @@ function usage() {
|
|
|
5
5
|
'aiwg artifacts — Manage the project AIWG artifact root',
|
|
6
6
|
'',
|
|
7
7
|
'Usage:',
|
|
8
|
-
' aiwg artifacts move --to <path> [--from <path>] [--dry-run] [--
|
|
8
|
+
' aiwg artifacts move --to <path> [--from <path>] [--dry-run] [--no-reindex] [--no-sync]',
|
|
9
|
+
' aiwg artifacts attach --to <existing-path> [--dry-run] [--no-reindex] [--no-sync]',
|
|
9
10
|
'',
|
|
10
11
|
'Notes:',
|
|
12
|
+
' move relocates a local artifact root; attach adopts an existing populated root.',
|
|
11
13
|
' --to points at the artifact directory itself, not its parent.',
|
|
12
14
|
' AIWG_ARTIFACTS_PATH overrides the generated .aiwg-location pointer.',
|
|
13
15
|
].join('\n');
|
|
@@ -30,7 +32,7 @@ export const artifactsHandler = {
|
|
|
30
32
|
if (action === 'help' || ctx.args.includes('--help') || ctx.args.includes('-h')) {
|
|
31
33
|
return { exitCode: 0, message: usage() };
|
|
32
34
|
}
|
|
33
|
-
if (action !== 'move') {
|
|
35
|
+
if (action !== 'move' && action !== 'attach') {
|
|
34
36
|
return { exitCode: 1, message: `Unknown artifacts action: ${action}\n\n${usage()}` };
|
|
35
37
|
}
|
|
36
38
|
const to = valueAfter(ctx.args, '--to');
|
|
@@ -42,12 +44,15 @@ export const artifactsHandler = {
|
|
|
42
44
|
projectDir: getProjectDir(ctx, ctx.args),
|
|
43
45
|
from: valueAfter(ctx.args, '--from'),
|
|
44
46
|
to,
|
|
47
|
+
attach: action === 'attach',
|
|
45
48
|
dryRun: ctx.dryRun || ctx.args.includes('--dry-run'),
|
|
46
49
|
force: ctx.args.includes('--force'),
|
|
47
50
|
reindex: !ctx.args.includes('--no-reindex'),
|
|
48
51
|
syncFortemi: !ctx.args.includes('--no-sync'),
|
|
49
52
|
});
|
|
50
|
-
const verb = result.dryRun
|
|
53
|
+
const verb = result.dryRun
|
|
54
|
+
? (action === 'attach' ? 'Would attach' : 'Would move')
|
|
55
|
+
: (result.attached ? 'Attached' : 'Moved');
|
|
51
56
|
return {
|
|
52
57
|
exitCode: 0,
|
|
53
58
|
message: [
|
package/dist/src/mcp/cli.mjs
CHANGED
|
@@ -415,7 +415,7 @@ CORE TOOLS (15, always registered):
|
|
|
415
415
|
command-run Allow-listed CLI dispatch; confirmation-gated when needed
|
|
416
416
|
artifact-read / artifact-write Project .aiwg/ artifact IO
|
|
417
417
|
|
|
418
|
-
OPT-IN TOOLSETS (
|
|
418
|
+
OPT-IN TOOLSETS (60 additional tools):
|
|
419
419
|
flows flow-list / flow-show / flow-run
|
|
420
420
|
missions mission-guide / mission-dispatch / mission-status
|
|
421
421
|
memory memory-* and reflections-* storage operations
|
|
@@ -426,9 +426,10 @@ OPT-IN TOOLSETS (51 additional tools):
|
|
|
426
426
|
ralph start / status / abort / attach
|
|
427
427
|
mc start / dispatch / status / stop / list
|
|
428
428
|
ops status / list / use / push
|
|
429
|
+
sandbox fleet inventory / mutation / reconciliation and governed activity
|
|
429
430
|
|
|
430
431
|
Enable opt-in tools:
|
|
431
|
-
AIWG_MCP_TOOLSETS=flows,missions,memory,kb,ralph aiwg mcp serve
|
|
432
|
+
AIWG_MCP_TOOLSETS=flows,missions,memory,kb,ralph,sandbox aiwg mcp serve
|
|
432
433
|
aiwg mcp serve --toolsets=all
|
|
433
434
|
|
|
434
435
|
RESOURCES:
|
|
@@ -451,6 +452,8 @@ TRANSPORTS:
|
|
|
451
452
|
ENVIRONMENT:
|
|
452
453
|
AIWG_ROOT Path to AIWG installation (default: ~/.local/share/ai-writing-guide)
|
|
453
454
|
AIWG_MCP_TOOLSETS Comma-separated opt-in toolsets; use all for every toolset
|
|
455
|
+
AIWG_SANDBOX_MANAGEMENT_URL Sandbox management API origin (HTTPS except loopback)
|
|
456
|
+
AIWG_SANDBOX_MANAGEMENT_TOKEN_FILE Mode-0600 management bearer file for sandbox tools
|
|
454
457
|
|
|
455
458
|
Docs:
|
|
456
459
|
docs/integrations/mcp-capability-audit.md
|
package/dist/src/mcp/server.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function createServer() {
|
|
|
68
68
|
// Opt-in subsystem toolsets (#1322-#1332)
|
|
69
69
|
//
|
|
70
70
|
// Enabled via AIWG_MCP_TOOLSETS env or `aiwg mcp serve --toolsets=`.
|
|
71
|
-
// Known: flows, missions, memory, kb, research, activity-log, index, ralph, mc, ops, all
|
|
71
|
+
// Known: flows, missions, memory, kb, research, activity-log, index, ralph, mc, ops, sandbox, all
|
|
72
72
|
// Default: none (core only — discovery + command-run)
|
|
73
73
|
// ============================================
|
|
74
74
|
const requested = parseToolsets(process.env.AIWG_MCP_TOOLSETS || '');
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed Agentic Sandbox fleet and activity MCP surface.
|
|
3
|
+
*
|
|
4
|
+
* Credentials are file-backed server configuration, never tool arguments.
|
|
5
|
+
* Every tool in this module uses the management bearer domain; executor-plane
|
|
6
|
+
* credentials are deliberately out of scope and cannot be substituted.
|
|
7
|
+
*
|
|
8
|
+
* @implements #2015
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { mcpError, mcpJson } from '../helpers.mjs';
|
|
14
|
+
|
|
15
|
+
const FLEET_VERSION = 'agentic-orchestration/v1';
|
|
16
|
+
const ACTIVITY_VERSION = 'activity.event/v1';
|
|
17
|
+
const SCOPE_HEADERS = {
|
|
18
|
+
tenant_id: 'x-agentic-tenant-id',
|
|
19
|
+
host_id: 'x-agentic-host-id',
|
|
20
|
+
instance_id: 'x-agentic-instance-id',
|
|
21
|
+
agent_id: 'x-agentic-agent-id',
|
|
22
|
+
};
|
|
23
|
+
const RESTRICTED_KEY = /(?:^|_)(?:content|terminal|prompt|environment|env|credential|secret|password|authorization|bearer|token|private_key|certificate|restricted_(?:url|uri|link))(?:$|_)/i;
|
|
24
|
+
|
|
25
|
+
function configuredBaseUrl(env = process.env) {
|
|
26
|
+
const raw = String(env.AIWG_SANDBOX_MANAGEMENT_URL ?? '').trim();
|
|
27
|
+
if (!raw) throw new Error('AIWG_SANDBOX_MANAGEMENT_URL is required for the sandbox toolset');
|
|
28
|
+
const url = new URL(raw);
|
|
29
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
30
|
+
throw new Error('AIWG_SANDBOX_MANAGEMENT_URL must be an HTTP(S) origin without credentials, query, or fragment');
|
|
31
|
+
}
|
|
32
|
+
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
|
|
33
|
+
throw new Error('AIWG_SANDBOX_MANAGEMENT_URL requires HTTPS outside loopback');
|
|
34
|
+
}
|
|
35
|
+
return url.toString().replace(/\/$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function managementBearer(env = process.env) {
|
|
39
|
+
const tokenFile = String(env.AIWG_SANDBOX_MANAGEMENT_TOKEN_FILE ?? '').trim();
|
|
40
|
+
if (!tokenFile) throw new Error('AIWG_SANDBOX_MANAGEMENT_TOKEN_FILE is required for the sandbox toolset');
|
|
41
|
+
const metadata = await stat(tokenFile);
|
|
42
|
+
if (!metadata.isFile()) throw new Error('sandbox management token path is not a regular file');
|
|
43
|
+
if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
|
|
44
|
+
throw new Error('sandbox management token file must not be accessible by group or other users');
|
|
45
|
+
}
|
|
46
|
+
const token = String(await readFile(tokenFile, 'utf8')).trim();
|
|
47
|
+
if (!token || /[\r\n]/.test(token)) throw new Error('sandbox management token file must contain one non-empty bearer token');
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function containsRestricted(value) {
|
|
52
|
+
if (Array.isArray(value)) return value.some(containsRestricted);
|
|
53
|
+
if (!value || typeof value !== 'object') return false;
|
|
54
|
+
return Object.entries(value).some(([key, child]) => RESTRICTED_KEY.test(key) || containsRestricted(child));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requireSafePayload(value, label) {
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
59
|
+
if (containsRestricted(value)) throw new Error(`${label} contains credential or restricted-content fields`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeResponse(value) {
|
|
64
|
+
if (containsRestricted(value)) throw Object.assign(new Error('sandbox response contained prohibited credential or restricted-content fields'), { code: 'sandbox_restricted_response' });
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requireFleetRecord(record) {
|
|
69
|
+
requireSafePayload(record, 'fleet workload');
|
|
70
|
+
if (record.document_type !== 'workload' || record.api_version !== FLEET_VERSION) throw new Error(`fleet workload must use ${FLEET_VERSION}`);
|
|
71
|
+
if (!record.lineage || typeof record.lineage.child_id !== 'string' || !record.lineage.child_id) throw new Error('fleet workload requires lineage.child_id');
|
|
72
|
+
if (!record.status || !Number.isInteger(record.status.revision) || record.status.revision < 0) throw new Error('fleet workload requires a non-negative status.revision');
|
|
73
|
+
return record;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requireInventory(value) {
|
|
77
|
+
safeResponse(value);
|
|
78
|
+
if (value?.document_type !== 'inventory' || value?.api_version !== FLEET_VERSION || !Number.isInteger(value?.inventory_revision) || !Array.isArray(value?.records)) {
|
|
79
|
+
throw new Error('invalid fleet inventory envelope');
|
|
80
|
+
}
|
|
81
|
+
value.records.forEach(requireFleetRecord);
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireReconciliation(value) {
|
|
86
|
+
safeResponse(value);
|
|
87
|
+
if (value?.document_type !== 'reconciliation' || value?.api_version !== FLEET_VERSION || !Number.isInteger(value?.before_revision) || !Number.isInteger(value?.after_revision) || !Array.isArray(value?.rows)) {
|
|
88
|
+
throw new Error('invalid fleet reconciliation envelope');
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireActivityEnvelope(value, scope, { eventsRequired = false, exportEnvelope = false } = {}) {
|
|
94
|
+
safeResponse(value);
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid activity envelope');
|
|
96
|
+
if (!exportEnvelope && value.schema_version !== ACTIVITY_VERSION) throw new Error(`activity envelope must use ${ACTIVITY_VERSION}`);
|
|
97
|
+
if (!exportEnvelope && (!Array.isArray(value.coverage) || typeof value.completeness?.complete !== 'boolean')) throw new Error('activity envelope requires coverage and completeness');
|
|
98
|
+
if (eventsRequired && !Array.isArray(value.events)) throw new Error('activity timeline requires events');
|
|
99
|
+
for (const event of value.events ?? []) {
|
|
100
|
+
if (
|
|
101
|
+
event?.schema_version !== ACTIVITY_VERSION
|
|
102
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(event?.event_id ?? '')
|
|
103
|
+
|| !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(event?.event_name ?? '')
|
|
104
|
+
|| !['session', 'action', 'network', 'runtime', 'system', 'integrity'].includes(event?.plane)
|
|
105
|
+
|| Number.isNaN(Date.parse(event?.occurred_at ?? ''))
|
|
106
|
+
|| Number.isNaN(Date.parse(event?.observed_at ?? ''))
|
|
107
|
+
|| !event?.source || typeof event.source.collector !== 'string' || !event.source.collector
|
|
108
|
+
|| !['guest', 'runtime', 'host', 'control-plane', 'provider'].includes(event.source.layer)
|
|
109
|
+
|| !['qemu-kvm', 'cloud-hypervisor', 'docker', 'host', 'unknown'].includes(event.source.runtime)
|
|
110
|
+
|| !['observed', 'attested', 'self-reported', 'derived'].includes(event.source.trust)
|
|
111
|
+
|| event?.sensitivity !== 'metadata'
|
|
112
|
+
|| !['standard', 'security', 'forensic-hold', 'ephemeral'].includes(event?.retention_class)
|
|
113
|
+
|| !event?.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)
|
|
114
|
+
|| !Number.isInteger(event?.integrity?.collector_sequence) || event.integrity.collector_sequence < 1
|
|
115
|
+
) throw new Error('activity event violates schema or sensitivity policy');
|
|
116
|
+
for (const [key, expected] of Object.entries(scope)) if (event?.correlation?.[key] !== expected) throw new Error(`activity event ${key} scope mismatch`);
|
|
117
|
+
}
|
|
118
|
+
if (exportEnvelope) {
|
|
119
|
+
const manifest = value.manifest;
|
|
120
|
+
if (!manifest || manifest.tenant_id !== scope.tenant_id || !Number.isInteger(manifest.event_count) || manifest.event_count < 0 || !/^[0-9a-f]{64}$/.test(manifest.merkle_root ?? '') || typeof manifest.key_id !== 'string' || !manifest.key_id || typeof manifest.signature !== 'string' || !manifest.signature) {
|
|
121
|
+
throw new Error('activity export manifest is malformed or out of scope');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function typedResult(body, status, validate) {
|
|
128
|
+
if (status === 404 || status === 405) return mcpJson({ supported: false, reason: 'capability_absent', status });
|
|
129
|
+
if (status < 200 || status >= 300) {
|
|
130
|
+
const safe = safeResponse(body);
|
|
131
|
+
return {
|
|
132
|
+
...mcpJson({ supported: true, ok: false, status, error_code: safe?.error ?? safe?.code ?? `http_${status}`, details: safe }),
|
|
133
|
+
isError: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return mcpJson({ supported: true, ok: true, status, data: validate(body) });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class AgenticSandboxMcpClient {
|
|
140
|
+
constructor({ env = process.env, fetch = globalThis.fetch.bind(globalThis) } = {}) {
|
|
141
|
+
this.env = env;
|
|
142
|
+
this.fetch = fetch;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async request(path, { method = 'GET', body, headers = {}, validate = (value) => safeResponse(value) } = {}) {
|
|
146
|
+
const baseUrl = configuredBaseUrl(this.env);
|
|
147
|
+
const token = await managementBearer(this.env);
|
|
148
|
+
const response = await this.fetch(`${baseUrl}${path}`, {
|
|
149
|
+
method,
|
|
150
|
+
headers: { accept: 'application/json', authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers },
|
|
151
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
152
|
+
});
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = await response.json();
|
|
156
|
+
} catch {
|
|
157
|
+
if (response.status === 404 || response.status === 405) parsed = {};
|
|
158
|
+
else return mcpError(`sandbox returned non-JSON HTTP ${response.status}`);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
return typedResult(parsed, response.status, validate);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return mcpError(`${error.code ?? 'sandbox_malformed_response'}: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const scopeSchema = {
|
|
169
|
+
tenant_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
170
|
+
host_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
171
|
+
instance_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
172
|
+
agent_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
173
|
+
};
|
|
174
|
+
const activityFilterSchema = z.object({
|
|
175
|
+
event_name: z.string().min(1).max(255).optional(), collector: z.string().min(1).max(255).optional(),
|
|
176
|
+
trust: z.string().min(1).max(255).optional(), plane: z.string().min(1).max(255).optional(),
|
|
177
|
+
outcome: z.string().min(1).max(255).optional(), session_id: z.string().min(1).max(255).optional(),
|
|
178
|
+
mission_id: z.string().min(1).max(255).optional(), task_id: z.string().min(1).max(255).optional(),
|
|
179
|
+
tool_call_id: z.string().min(1).max(255).optional(), command_id: z.string().min(1).max(255).optional(),
|
|
180
|
+
process_id: z.string().min(1).max(255).optional(), trace_id: z.string().regex(/^[0-9a-f]{32}$/).optional(),
|
|
181
|
+
since: z.string().datetime().optional(), until: z.string().datetime().optional(), limit: z.number().int().min(1).max(1000).optional(),
|
|
182
|
+
}).strict().optional();
|
|
183
|
+
|
|
184
|
+
function scopeHeaders(args) {
|
|
185
|
+
return Object.fromEntries(Object.entries(SCOPE_HEADERS).map(([key, header]) => [header, args[key]]));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function activityQuery(filter = {}) {
|
|
189
|
+
const query = new URLSearchParams();
|
|
190
|
+
for (const [key, value] of Object.entries(filter)) query.set(key, String(value));
|
|
191
|
+
return query.size ? `?${query}` : '';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function confirmationError(name) {
|
|
195
|
+
return mcpError(`${name} requires confirmed=true`, { requiresConfirmation: true, remediation: 'Review the exact scope and payload, then re-invoke with confirmed=true.' });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function registerAgenticSandboxToolset(server, { client = new AgenticSandboxMcpClient() } = {}) {
|
|
199
|
+
const register = (name, config, handler) => server.registerTool(name, config, async (args) => {
|
|
200
|
+
try { return await handler(args); } catch (error) { return mcpError(`${name}: ${error.message}`); }
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
register('sandbox-fleet-list', { title: 'List Agentic Sandbox fleet workloads', description: 'Read revisioned v2026.8.3+ fleet inventory using the management credential domain.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION) }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
204
|
+
() => client.request('/api/v2/fleet/workloads', { validate: requireInventory }));
|
|
205
|
+
register('sandbox-fleet-get', { title: 'Get Agentic Sandbox fleet workload', description: 'Read one revisioned fleet workload by child identity.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), child_id: z.string().min(1).max(255) }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
206
|
+
({ child_id }) => client.request(`/api/v2/fleet/workloads/${encodeURIComponent(child_id)}`, { validate: requireFleetRecord }));
|
|
207
|
+
register('sandbox-fleet-reconcile-preview', { title: 'Preview fleet reconciliation', description: 'Compute a read-only reconciliation preview from inventory; never calls POST /reconcile.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), before_revision: z.number().int().min(0), child_ids: z.array(z.string().min(1).max(255)).max(1000) }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
208
|
+
async ({ before_revision, child_ids }) => client.request('/api/v2/fleet/workloads', { validate: (value) => {
|
|
209
|
+
const inventory = requireInventory(value);
|
|
210
|
+
const byId = new Map(inventory.records.map((record) => [record.lineage.child_id, record]));
|
|
211
|
+
return { document_type: 'reconciliation-preview', api_version: FLEET_VERSION, before_revision, inventory_revision: inventory.inventory_revision, stale: before_revision !== inventory.inventory_revision, rows: child_ids.map((child_id) => ({ child_id, present: byId.has(child_id), observed_state: byId.get(child_id)?.status?.observed_state ?? 'unknown', revision: byId.get(child_id)?.status?.revision ?? null })) };
|
|
212
|
+
} }));
|
|
213
|
+
register('sandbox-fleet-admit', { title: 'Admit Agentic Sandbox fleet workload', description: 'Mutating fleet admission; requires an exact v1 workload and confirmed=true.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), workload: z.record(z.unknown()), confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
214
|
+
({ workload, confirmed }) => confirmed ? client.request('/api/v2/fleet/workloads', { method: 'POST', body: requireFleetRecord(workload), validate: (value) => ({ replayed: value?.replayed === true, workload: requireFleetRecord(value?.workload) }) }) : confirmationError('sandbox-fleet-admit'));
|
|
215
|
+
register('sandbox-fleet-observe', { title: 'Record fleet workload observation', description: 'Mutating monotonic observation update; requires confirmed=true and expected revision.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), child_id: z.string().min(1).max(255), expected_revision: z.number().int().min(0), status: z.record(z.unknown()), runtime_identity: z.object({ session_id: z.string().optional(), task_id: z.string().optional(), command_id: z.string().optional() }).strict().optional(), confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
216
|
+
({ child_id, expected_revision, status, runtime_identity, confirmed }) => confirmed ? client.request(`/api/v2/fleet/workloads/${encodeURIComponent(child_id)}/observations`, { method: 'POST', body: requireSafePayload({ expected_revision, status, ...(runtime_identity ? { runtime_identity } : {}) }, 'fleet observation'), validate: requireFleetRecord }) : confirmationError('sandbox-fleet-observe'));
|
|
217
|
+
register('sandbox-fleet-reconcile', { title: 'Reconcile fleet workloads', description: 'Mutating restart reconciliation; requires confirmed=true.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), before_revision: z.number().int().min(0), child_ids: z.array(z.string().min(1).max(255)).max(1000), confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
218
|
+
({ before_revision, child_ids, confirmed }) => confirmed ? client.request('/api/v2/fleet/reconcile', { method: 'POST', body: { before_revision, child_ids }, validate: requireReconciliation }) : confirmationError('sandbox-fleet-reconcile'));
|
|
219
|
+
|
|
220
|
+
for (const [kind, eventsRequired] of [['coverage', false], ['timeline', true]]) {
|
|
221
|
+
register(`sandbox-activity-${kind}`, { title: `${kind === 'coverage' ? 'Inspect coverage for' : 'Read timeline from'} Agentic Sandbox activity`, description: `Read governed, exactly scoped activity ${kind}; preserves capability and authorization status.`, inputSchema: { contract_version: z.literal(ACTIVITY_VERSION).default(ACTIVITY_VERSION), ...scopeSchema, filter: activityFilterSchema }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
222
|
+
(args) => { const scope = Object.fromEntries(Object.keys(SCOPE_HEADERS).map((key) => [key, args[key]])); return client.request(`/api/v2/activity/${kind}${activityQuery(args.filter)}`, { headers: scopeHeaders(args), validate: (value) => requireActivityEnvelope(value, scope, { eventsRequired }) }); });
|
|
223
|
+
}
|
|
224
|
+
register('sandbox-activity-export', { title: 'Export signed Agentic Sandbox activity evidence', description: 'Evidence export requires confirmed=true even though it does not mutate server state.', inputSchema: { contract_version: z.literal(ACTIVITY_VERSION).default(ACTIVITY_VERSION), ...scopeSchema, filter: activityFilterSchema, confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
225
|
+
(args) => { if (!args.confirmed) return confirmationError('sandbox-activity-export'); const scope = Object.fromEntries(Object.keys(SCOPE_HEADERS).map((key) => [key, args[key]])); return client.request('/api/v2/activity/export', { method: 'POST', headers: scopeHeaders(args), body: args.filter ?? {}, validate: (value) => requireActivityEnvelope(value, scope, { eventsRequired: true, exportEnvelope: true }) }); });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const AGENTIC_SANDBOX_TOOL_NAMES = [
|
|
229
|
+
'sandbox-fleet-list', 'sandbox-fleet-get', 'sandbox-fleet-reconcile-preview',
|
|
230
|
+
'sandbox-fleet-admit', 'sandbox-fleet-observe', 'sandbox-fleet-reconcile',
|
|
231
|
+
'sandbox-activity-coverage', 'sandbox-activity-timeline', 'sandbox-activity-export',
|
|
232
|
+
];
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { runAiwgCli, mcpError, mcpJson } from '../helpers.mjs';
|
|
19
19
|
import { registerFlowToolset, registerMissionToolset } from './orchestration.mjs';
|
|
20
|
+
import { registerAgenticSandboxToolset } from './agentic-sandbox.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Wrap an `aiwg <subsystem> <verb>` CLI call as an MCP tool.
|
|
@@ -586,6 +587,7 @@ const TOOLSET_REGISTRY = {
|
|
|
586
587
|
ralph: registerRalphToolset,
|
|
587
588
|
mc: registerMcToolset,
|
|
588
589
|
ops: registerOpsToolset,
|
|
590
|
+
sandbox: registerAgenticSandboxToolset,
|
|
589
591
|
};
|
|
590
592
|
|
|
591
593
|
/**
|
|
@@ -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 = {}) {
|