@aiwg/cockpit 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.
@@ -8,7 +8,7 @@
8
8
  import http from 'node:http';
9
9
  import https from 'node:https';
10
10
  import { spawn } from 'node:child_process';
11
- import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
11
+ import { readFile, mkdir, writeFile, rename, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
12
12
  import { existsSync, realpathSync } from 'node:fs';
13
13
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
14
14
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -42,7 +42,13 @@ const auditLog = () => join(auditDir(), 'events.jsonl');
42
42
  // legacy vanilla page so the Bridge works even before a web build.
43
43
  const WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
44
44
  const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2', '.map': 'application/json' };
45
- const CAPABILITY_TYPES = new Set(['skill', 'agent', 'command', 'rule', 'flow']);
45
+ // Discovery indexes more than the four executable provider artifacts. Keep the
46
+ // Bridge filter aligned with the complete corpus so Explore does not hide
47
+ // extension and documentation surfaces (#1592).
48
+ const CAPABILITY_TYPES = new Set([
49
+ 'skill', 'agent', 'command', 'rule', 'flow', 'behavior', 'hook', 'template',
50
+ 'tool', 'addon', 'framework', 'extension', 'plugin', 'provider', 'document',
51
+ ]);
46
52
  const mcSessionsDir = () => join(process.cwd(), '.aiwg', 'ralph-external', 'mc', 'sessions');
47
53
  const executorRequestContext = new AsyncLocalStorage();
48
54
 
@@ -177,13 +183,88 @@ function spawnCollect(cmd, args) {
177
183
  p.stdout.on('data', (d) => (out += d));
178
184
  p.stderr.on('data', (d) => (err += d));
179
185
  p.once('error', reject);
180
- p.once('close', (code) => (code === 0 ? resolve(out) : reject(new Error(err.trim() || `aiwg exit ${code}`))));
186
+ p.once('close', (code) => {
187
+ if (code === 0) return resolve(out);
188
+ const failure = new Error(err.trim() || `aiwg exit ${code}`);
189
+ failure.exitCode = code;
190
+ failure.stdout = out;
191
+ reject(failure);
192
+ });
181
193
  });
182
194
  }
183
195
  async function runAiwg(args) {
184
196
  try { return await spawnCollect('aiwg', args); }
185
197
  catch (e) { if (e && e.code === 'ENOENT') return spawnCollect(process.execPath, [REPO_BIN, ...args]); throw e; }
186
198
  }
199
+
200
+ const MISSION_CONTROL_ID_RE = /^[a-zA-Z0-9._-]+$/;
201
+ async function controlMission({ action, sessionId, missionId, expectedUpdatedAt, requestId }) {
202
+ if (!['pause', 'resume', 'cancel'].includes(action)) throw Object.assign(new Error('unsupported mission control action'), { status: 400 });
203
+ if (!MISSION_CONTROL_ID_RE.test(sessionId) || (missionId && !MISSION_CONTROL_ID_RE.test(missionId))) {
204
+ throw Object.assign(new Error('invalid Mission control identifier'), { status: 400 });
205
+ }
206
+ const args = ['mc', action, sessionId];
207
+ if (action === 'cancel') {
208
+ if (!missionId) throw Object.assign(new Error('mission id required'), { status: 400 });
209
+ args.push(missionId);
210
+ }
211
+ if (expectedUpdatedAt) args.push('--expected-updated-at', String(expectedUpdatedAt));
212
+ if (requestId) args.push('--request-id', String(requestId));
213
+ await appendAudit('mission.control.requested', {
214
+ action,
215
+ session_id: sessionId,
216
+ mission_id: missionId ?? null,
217
+ expected_updated_at: expectedUpdatedAt ?? null,
218
+ request_id: requestId ?? null,
219
+ });
220
+ try {
221
+ await runAiwg(args);
222
+ } catch (error) {
223
+ const message = String(error?.message ?? error);
224
+ const status = error?.exitCode === 3 || /mission_conflict/.test(message) ? 409 : 422;
225
+ await appendAudit('mission.control.rejected', { action, session_id: sessionId, mission_id: missionId ?? null, request_id: requestId ?? null, status, reason: message });
226
+ throw Object.assign(new Error(message), { status });
227
+ }
228
+ await appendAudit('mission.control.completed', { action, session_id: sessionId, mission_id: missionId ?? null, request_id: requestId ?? null });
229
+ return { ok: true, action, session_id: sessionId, mission_id: missionId ?? null, request_id: requestId ?? null };
230
+ }
231
+
232
+ async function dispatchMission(body, upstreamUrl) {
233
+ const sessionId = String(body?.session_id ?? '');
234
+ const objective = String(body?.objective ?? '').trim();
235
+ const completion = String(body?.completion ?? '').trim();
236
+ const requestId = String(body?.request_id ?? randomBytes(16).toString('hex'));
237
+ if (!MISSION_CONTROL_ID_RE.test(sessionId)) throw Object.assign(new Error('invalid Mission control session id'), { status: 400 });
238
+ if (!objective || objective.length > 4096) throw Object.assign(new Error('objective is required and must be at most 4096 characters'), { status: 400 });
239
+ if (completion.length > 4096) throw Object.assign(new Error('completion must be at most 4096 characters'), { status: 400 });
240
+ if (!MISSION_CONTROL_ID_RE.test(requestId)) throw Object.assign(new Error('request_id must contain only letters, digits, dot, underscore, or hyphen'), { status: 400 });
241
+ const args = ['mc', 'dispatch', sessionId, objective, '--request-id', requestId];
242
+ if (completion) args.push('--completion', completion);
243
+ if (body?.priority) args.push('--priority', String(body.priority));
244
+ if (body?.expected_updated_at) args.push('--expected-updated-at', String(body.expected_updated_at));
245
+ if (body?.max_iterations !== undefined) {
246
+ const maxIterations = Number(body.max_iterations);
247
+ if (!Number.isInteger(maxIterations) || maxIterations < 1 || maxIterations > 10_000) {
248
+ throw Object.assign(new Error('max_iterations must be an integer from 1 to 10000'), { status: 400 });
249
+ }
250
+ args.push('--max-iterations', String(maxIterations));
251
+ }
252
+ await appendAudit('mission.dispatch.requested', { session_id: sessionId, request_id: requestId, objective_digest: `sha256:${createHash('sha256').update(objective).digest('hex')}` });
253
+ try {
254
+ await runAiwg(args);
255
+ if (body?.run === true) {
256
+ await runAiwg(['mc', 'run', sessionId, ...(body?.accept_cost === true ? ['--accept-cost'] : [])]);
257
+ }
258
+ } catch (error) {
259
+ const message = String(error?.message ?? error);
260
+ const status = error?.exitCode === 3 || /mission_conflict/.test(message) ? 409 : 422;
261
+ await appendAudit('mission.dispatch.rejected', { session_id: sessionId, request_id: requestId, status, reason: message });
262
+ throw Object.assign(new Error(message), { status });
263
+ }
264
+ const missionId = `m-${createHash('sha256').update(requestId).digest('hex').slice(0, 16)}`;
265
+ await appendAudit('mission.dispatch.completed', { session_id: sessionId, mission_id: missionId, request_id: requestId, run: body?.run === true });
266
+ return { ok: true, session_id: sessionId, mission_id: missionId, request_id: requestId, projection: await getMissions(upstreamUrl) };
267
+ }
187
268
  // --- user asset library (#1591/#1593): the operator's OWN copied/cloned/imported
188
269
  // assets, on disk under ~/.aiwg/cockpit/library. AIWG install files are NEVER written
189
270
  // (clone reads the catalog read-only, writes only into the library). ---
@@ -256,23 +337,32 @@ function resolveCorpusPath(p) {
256
337
  // --- UI contribution model (#1591): declarative screens/actions/event-hooks ---
257
338
  const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
258
339
  /** Validate one contribution manifest. Throws with a precise message on bad shape. */
259
- function validateContribution(m, where) {
340
+ function validateContribution(m, where, { firstParty = false } = {}) {
260
341
  const fail = (msg) => { throw new Error(`${where}: ${msg}`); };
261
342
  if (!m || typeof m !== 'object') fail('manifest must be an object');
262
343
  if (!ID_RE.test(m.id || '')) fail('id must match [a-z0-9._-]{1,64}');
263
- if (typeof m.version !== 'string') fail('version (string) required');
344
+ if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(m.version)) fail('version must be semantic version syntax');
264
345
  const c = m.contributes || {};
346
+ const actionIds = new Set();
265
347
  for (const a of c.actions || []) {
266
348
  if (!ID_RE.test(a.id || '')) fail(`action.id invalid: ${a.id}`);
349
+ if (actionIds.has(a.id)) fail(`duplicate action.id: ${a.id}`);
350
+ actionIds.add(a.id);
267
351
  if (typeof a.title !== 'string') fail(`action ${a.id}: title required`);
268
352
  // An action INJECTS a command into an agentic session — it does NOT run the CLI.
269
353
  if (!a.inject || typeof a.inject.command !== 'string') fail(`action ${a.id}: inject.command (string) required`);
354
+ if (!/^\/[a-z0-9][a-z0-9._-]*(?:\s[^\r\n]*)?$/i.test(a.inject.command)) fail(`action ${a.id}: inject.command must be one slash command without newlines`);
270
355
  if (a.inject.target && !['focused', 'new'].includes(a.inject.target)) fail(`action ${a.id}: inject.target must be focused|new`);
271
356
  }
272
357
  for (const s of c.screens || []) {
273
358
  if (!ID_RE.test(s.id || '')) fail(`screen.id invalid: ${s.id}`);
274
359
  if (typeof s.title !== 'string') fail(`screen ${s.id}: title required`);
275
360
  if (typeof s.source !== 'string') fail(`screen ${s.id}: source required`);
361
+ if (firstParty) {
362
+ if (!s.source.startsWith('cockpit://')) fail(`screen ${s.id}: first-party source must use cockpit://`);
363
+ } else if (!s.source.startsWith(`sandbox://${m.id}/`)) {
364
+ fail(`screen ${s.id}: third-party source must use sandbox://${m.id}/`);
365
+ }
276
366
  }
277
367
  for (const w of c.workflows || []) {
278
368
  if (!ID_RE.test(w.id || '')) fail(`workflow.id invalid: ${w.id}`);
@@ -280,24 +370,39 @@ function validateContribution(m, where) {
280
370
  if (!Array.isArray(w.steps) || w.steps.length === 0) fail(`workflow ${w.id}: steps required`);
281
371
  for (const step of w.steps) {
282
372
  if (!step || typeof step !== 'object' || !ID_RE.test(step.action || '')) fail(`workflow ${w.id}: step.action invalid`);
373
+ if (!actionIds.has(step.action)) fail(`workflow ${w.id}: unknown action ${step.action}`);
283
374
  }
284
375
  }
285
- for (const h of c.hooks || []) { if (typeof h.on !== 'string' || !ID_RE.test(h.action || '')) fail(`hook invalid: on=${h.on}`); }
376
+ for (const h of c.hooks || []) {
377
+ if (typeof h.on !== 'string' || !ID_RE.test(h.action || '') || !actionIds.has(h.action)) fail(`hook invalid: on=${h.on}`);
378
+ }
286
379
  return m;
287
380
  }
288
381
  /** Load + validate + merge all contribution manifests across the configured dirs. */
289
382
  async function loadContributions() {
290
383
  const sources = [], actions = [], screens = [], hooks = [], workflows = [];
291
- for (const dir of CONTRIB_DIRS) {
384
+ const manifestIds = new Set();
385
+ const itemIds = new Set();
386
+ for (const [dirIndex, dir] of CONTRIB_DIRS.entries()) {
387
+ const trustTier = dirIndex === 0 ? 'first-party' : 'sandboxed-third-party';
292
388
  let entries = [];
293
389
  try { entries = (await readdir(dir)).filter((f) => f.endsWith('.json') && f !== 'contribution.schema.json'); } catch { continue; }
294
390
  for (const file of entries) {
295
- const m = validateContribution(JSON.parse(await readFile(join(dir, file), 'utf8')), file);
296
- sources.push({ id: m.id, version: m.version, title: m.title ?? m.id, file });
297
- for (const a of m.contributes?.actions || []) actions.push({ ...a, source: m.id });
298
- for (const s of m.contributes?.screens || []) screens.push({ ...s, contribution: m.id });
299
- for (const h of m.contributes?.hooks || []) hooks.push({ ...h, source: m.id });
300
- for (const w of m.contributes?.workflows || []) workflows.push({ ...w, source: m.id });
391
+ const m = validateContribution(JSON.parse(await readFile(join(dir, file), 'utf8')), file, { firstParty: dirIndex === 0 });
392
+ if (manifestIds.has(m.id)) throw new Error(`${file}: duplicate contribution id ${m.id}`);
393
+ manifestIds.add(m.id);
394
+ sources.push({ id: m.id, version: m.version, title: m.title ?? m.id, file, trust_tier: trustTier });
395
+ for (const [kind, rows] of Object.entries({ actions: m.contributes?.actions || [], screens: m.contributes?.screens || [], hooks: m.contributes?.hooks || [], workflows: m.contributes?.workflows || [] })) {
396
+ for (const row of rows) {
397
+ const globalId = `${kind}:${row.id ?? `${row.on}:${row.action}`}`;
398
+ if (itemIds.has(globalId)) throw new Error(`${file}: duplicate ${globalId}`);
399
+ itemIds.add(globalId);
400
+ }
401
+ }
402
+ for (const a of m.contributes?.actions || []) actions.push({ ...a, source: m.id, trust_tier: trustTier });
403
+ for (const s of m.contributes?.screens || []) screens.push({ ...s, contribution: m.id, trust_tier: trustTier });
404
+ for (const h of m.contributes?.hooks || []) hooks.push({ ...h, source: m.id, trust_tier: trustTier });
405
+ for (const w of m.contributes?.workflows || []) workflows.push({ ...w, source: m.id, trust_tier: trustTier });
301
406
  }
302
407
  }
303
408
  return { sources, actions, screens, hooks, workflows };
@@ -356,6 +461,42 @@ async function rebuildIndex(req) {
356
461
  return { status: 200, body: { ok: true, command: `aiwg ${args.join(' ')}`, output, status } };
357
462
  }
358
463
 
464
+ export async function createUserIndexGraph(body, projectRoot = process.cwd()) {
465
+ const name = safeIndexGraph(body?.name);
466
+ if (!name || ['project', 'codebase', 'framework'].includes(name)) {
467
+ throw new Error('name must be a non-built-in graph identifier');
468
+ }
469
+ const scanDirs = Array.isArray(body?.scanDirs) ? body.scanDirs.map((value) => String(value).trim()) : [];
470
+ if (!scanDirs.length || scanDirs.some((value) => !value || value.startsWith('/') || value.split(/[\\/]+/).includes('..'))) {
471
+ throw new Error('scanDirs must contain safe project-relative paths');
472
+ }
473
+ const extensions = Array.isArray(body?.extensions) && body.extensions.length
474
+ ? body.extensions.map((value) => String(value).trim())
475
+ : ['.md', '.yaml', '.json'];
476
+ if (extensions.some((value) => !/^\.[a-z0-9]+$/i.test(value))) {
477
+ throw new Error('extensions must use forms such as .md or .json');
478
+ }
479
+ const configDir = join(projectRoot, '.aiwg');
480
+ const configPath = join(configDir, 'aiwg.config');
481
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
482
+ let config = {};
483
+ try { config = JSON.parse(await readFile(configPath, 'utf8')); }
484
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
485
+ config.index = config.index && typeof config.index === 'object' ? config.index : {};
486
+ config.index.graphs = config.index.graphs && typeof config.index.graphs === 'object' ? config.index.graphs : {};
487
+ if (config.index.graphs[name]) throw new Error(`graph '${name}' already exists`);
488
+ config.index.graphs[name] = {
489
+ scanDirs,
490
+ extensions,
491
+ defaultBuild: body?.defaultBuild === true,
492
+ shared: body?.shared === true,
493
+ };
494
+ const temporary = `${configPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
495
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
496
+ await rename(temporary, configPath);
497
+ return { name, definition: config.index.graphs[name], config_path: configPath };
498
+ }
499
+
359
500
  function json(res, status, body) {
360
501
  res.writeHead(status, { 'content-type': 'application/json' });
361
502
  res.end(JSON.stringify(body));
@@ -1392,6 +1533,7 @@ function normalizeInstance(executorUrl, i) {
1392
1533
  ? { mode: i.transport, trust: i.transport_posture, source: 'agentic-sandbox admin-v2' }
1393
1534
  : i.transport ?? i.transport_posture ?? i.security_posture ?? i.security?.transport,
1394
1535
  ),
1536
+ managed_docker_posture: normalizeManagedDockerPosture(i, runtimePosture.kind),
1395
1537
  launch_context: {
1396
1538
  cwd: i.launch_context?.cwd ?? i.launchContext?.cwd ?? i.cwd,
1397
1539
  loadout,
@@ -1410,6 +1552,154 @@ function normalizeInstance(executorUrl, i) {
1410
1552
  };
1411
1553
  }
1412
1554
 
1555
+ const MANAGED_DOCKER_CONTROL_UID_MIN = 200_000;
1556
+ const MANAGED_DOCKER_CONTROL_UID_MAX = 799_999;
1557
+ const MANAGED_DOCKER_WORKLOAD_UID = 10_001;
1558
+
1559
+ /** Project only executor-attested, client-safe managed-Docker identity evidence. */
1560
+ export function normalizeManagedDockerPosture(i, runtimeKind) {
1561
+ if (!['docker', 'container'].includes(String(runtimeKind).toLowerCase())) return undefined;
1562
+ const source = i.managed_docker_posture ?? i.managedDockerPosture ?? i.security_posture ?? i.securityPosture ?? i;
1563
+ const rawTransport = source.transport_mode ?? source.transportMode
1564
+ ?? (typeof source.transport === 'string' ? source.transport : source.transport?.mode)
1565
+ ?? (typeof i.transport === 'string' ? i.transport : i.transport?.mode)
1566
+ ?? 'unknown';
1567
+ const transportMode = String(rawTransport).toLowerCase();
1568
+ const rawControlUid = source.control_uid ?? source.controlUid;
1569
+ const controlUid = Number.isInteger(Number(rawControlUid)) ? Number(rawControlUid) : undefined;
1570
+ const rawWorkloadUid = source.workload_uid ?? source.workloadUid;
1571
+ const workloadUid = Number.isInteger(Number(rawWorkloadUid)) ? Number(rawWorkloadUid) : undefined;
1572
+ const boundary = String(source.workload_boundary ?? source.workloadBoundary ?? source.boundary ?? 'unknown').toLowerCase();
1573
+ const reportedFallback = String(source.fallback_reason_code ?? source.fallbackReasonCode ?? source.fallback_reason ?? source.fallbackReason ?? '').toLowerCase();
1574
+ const fallbackReason = transportMode === 'mtls-bootstrap' || reportedFallback === 'docker_desktop_peer_uid_unavailable'
1575
+ ? 'Docker Desktop UDS bridge does not preserve peer UID'
1576
+ : reportedFallback === 'identity_resolver_unavailable'
1577
+ ? 'Managed UDS identity resolver unavailable'
1578
+ : ['operator-configured', 'explicit', 'mtls'].includes(transportMode)
1579
+ ? 'Operator-configured compatibility transport'
1580
+ : undefined;
1581
+ const controlIdentityPresent = controlUid !== undefined;
1582
+ const controlIdentityRangeValid = controlIdentityPresent
1583
+ && controlUid >= MANAGED_DOCKER_CONTROL_UID_MIN
1584
+ && controlUid <= MANAGED_DOCKER_CONTROL_UID_MAX;
1585
+ const workloadIdentitySeparated = boundary === 'separated' && workloadUid === MANAGED_DOCKER_WORKLOAD_UID;
1586
+ const secureDefault = transportMode === 'uds' && controlIdentityRangeValid && workloadIdentitySeparated;
1587
+ const compatibility = transportMode !== 'uds';
1588
+ const requiresRecreation = !controlIdentityPresent || !workloadUid || boundary === 'unknown';
1589
+ return {
1590
+ transport_mode: transportMode,
1591
+ control_identity_present: controlIdentityPresent,
1592
+ control_identity_range_valid: controlIdentityRangeValid,
1593
+ workload_uid: workloadUid,
1594
+ workload_identity_separated: workloadIdentitySeparated,
1595
+ boundary,
1596
+ secure_default: secureDefault,
1597
+ compatibility,
1598
+ fallback_reason: fallbackReason ? String(fallbackReason).slice(0, 300) : undefined,
1599
+ requires_recreation: requiresRecreation,
1600
+ source: 'agentic-sandbox',
1601
+ };
1602
+ }
1603
+
1604
+ const ACTIVITY_SCOPE_HEADERS = {
1605
+ tenant_id: 'x-agentic-tenant-id', host_id: 'x-agentic-host-id',
1606
+ instance_id: 'x-agentic-instance-id', agent_id: 'x-agentic-agent-id',
1607
+ };
1608
+ const ACTIVITY_FILTERS = new Set(['event_name', 'collector', 'trust', 'plane', 'outcome', 'session_id', 'mission_id', 'task_id', 'tool_call_id', 'command_id', 'process_id', 'trace_id', 'since', 'until', 'limit']);
1609
+ const RESTRICTED_ACTIVITY_KEY = /(?:^|_)(?:content|terminal|prompt|environment|env|credential|secret|password|authorization|bearer|token|private_key|certificate|restricted_(?:url|uri|link))(?:$|_)/i;
1610
+
1611
+ export function activityRequest(input = {}) {
1612
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw Object.assign(new Error('activity request must be an object'), { code: 'activity_invalid_request' });
1613
+ const headers = { 'accept': 'application/json' };
1614
+ const scope = {};
1615
+ for (const [key, header] of Object.entries(ACTIVITY_SCOPE_HEADERS)) {
1616
+ const value = String(input[key] ?? '').trim();
1617
+ if (!value || value.length > 255 || /[\r\n]/.test(value)) throw Object.assign(new Error(`missing or invalid ${key}`), { code: 'activity_scope_required' });
1618
+ headers[header] = value;
1619
+ scope[key] = value;
1620
+ }
1621
+ const filter = {};
1622
+ for (const [key, value] of Object.entries(input.filter ?? {})) {
1623
+ if (!ACTIVITY_FILTERS.has(key)) throw Object.assign(new Error(`unsupported activity filter: ${key}`), { code: 'activity_invalid_filter' });
1624
+ if (key === 'limit') {
1625
+ if (!Number.isInteger(value) || value < 1 || value > 1000) throw Object.assign(new Error('activity limit must be 1..1000'), { code: 'activity_invalid_filter' });
1626
+ filter[key] = value;
1627
+ } else if (typeof value === 'string' && value.trim() && value.length <= 255 && !/[\r\n]/.test(value)) filter[key] = value.trim();
1628
+ else throw Object.assign(new Error(`invalid activity filter: ${key}`), { code: 'activity_invalid_filter' });
1629
+ }
1630
+ return { headers, scope, filter };
1631
+ }
1632
+
1633
+ function hasRestrictedActivityField(value) {
1634
+ if (Array.isArray(value)) return value.some(hasRestrictedActivityField);
1635
+ if (!value || typeof value !== 'object') return false;
1636
+ return Object.entries(value).some(([key, child]) => RESTRICTED_ACTIVITY_KEY.test(key) || hasRestrictedActivityField(child));
1637
+ }
1638
+
1639
+ export function validateActivityEnvelope(body, expectedScope, { includeEvents = false, exportEnvelope = false } = {}) {
1640
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new Error('malformed activity envelope'), { code: 'activity_malformed_envelope' });
1641
+ if (!exportEnvelope && body.schema_version !== 'activity.event/v1') throw Object.assign(new Error('unsupported activity schema'), { code: 'activity_malformed_envelope' });
1642
+ const events = Array.isArray(body.events) ? body.events : [];
1643
+ if (includeEvents && !Array.isArray(body.events)) throw Object.assign(new Error('activity envelope has no events array'), { code: 'activity_malformed_envelope' });
1644
+ if (!exportEnvelope && (!Array.isArray(body.coverage) || !body.completeness || typeof body.completeness.complete !== 'boolean')) {
1645
+ throw Object.assign(new Error('activity envelope has invalid coverage'), { code: 'activity_malformed_envelope' });
1646
+ }
1647
+ const nonnegativeInteger = (value) => Number.isInteger(value) && value >= 0;
1648
+ const nonnegativeFinite = (value) => Number.isFinite(value) && value >= 0;
1649
+ const validCompleteness = (value) => value
1650
+ && typeof value.label === 'string'
1651
+ && nonnegativeInteger(value.collector_count)
1652
+ && nonnegativeInteger(value.sequence_gap_count)
1653
+ && nonnegativeInteger(value.durable_loss_count)
1654
+ && nonnegativeInteger(value.restart_count)
1655
+ && nonnegativeInteger(value.dropped_event_count)
1656
+ && nonnegativeInteger(value.stale_collector_count)
1657
+ && Array.isArray(value.unsupported_event_classes)
1658
+ && value.unsupported_event_classes.every((item) => typeof item === 'string')
1659
+ && nonnegativeFinite(value.maximum_clock_error_ms);
1660
+ if (!exportEnvelope && !validCompleteness(body.completeness)) {
1661
+ throw Object.assign(new Error('activity envelope has malformed completeness summary'), { code: 'activity_malformed_envelope' });
1662
+ }
1663
+ if (!exportEnvelope && body.coverage.some((entry) => !entry || typeof entry.collector_id !== 'string' || !Array.isArray(entry.sequence_gaps) || !Array.isArray(entry.durable_loss_records) || !nonnegativeInteger(entry.restart_count) || !nonnegativeInteger(entry.dropped_event_count) || typeof entry.stale !== 'boolean' || !Array.isArray(entry.unsupported_event_classes) || !entry.unsupported_event_classes.every((item) => typeof item === 'string') || !nonnegativeFinite(entry.maximum_clock_error_ms))) {
1664
+ throw Object.assign(new Error('activity envelope has malformed collector coverage'), { code: 'activity_malformed_envelope' });
1665
+ }
1666
+ for (const event of events) {
1667
+ if (event?.schema_version !== 'activity.event/v1' || event?.sensitivity !== 'metadata' || hasRestrictedActivityField(event)) {
1668
+ throw Object.assign(new Error('activity envelope contains restricted or unsupported event data'), { code: 'activity_restricted_data' });
1669
+ }
1670
+ for (const [key, value] of Object.entries(expectedScope)) {
1671
+ if (event?.correlation?.[key] !== value) throw Object.assign(new Error('activity event scope mismatch'), { code: 'activity_scope_mismatch' });
1672
+ }
1673
+ }
1674
+ if (exportEnvelope && (!body.manifest || typeof body.manifest.key_id !== 'string' || typeof body.manifest.merkle_root !== 'string')) {
1675
+ throw Object.assign(new Error('signed activity export has no valid manifest'), { code: 'activity_malformed_export' });
1676
+ }
1677
+ return body;
1678
+ }
1679
+
1680
+ async function activityProxy(executorUrl, kind, input) {
1681
+ const request = activityRequest(input);
1682
+ const isExport = kind === 'export';
1683
+ const query = new URLSearchParams(Object.entries(request.filter).map(([key, value]) => [key, String(value)]));
1684
+ const target = `${executorUrl}/api/v2/activity/${kind}${!isExport && query.size ? `?${query}` : ''}`;
1685
+ const result = await fetchJsonFirst([{ target, method: isExport ? 'POST' : 'GET', headers: { ...request.headers, ...(isExport ? { 'content-type': 'application/json' } : {}) }, body: isExport ? JSON.stringify(request.filter) : undefined }]);
1686
+ if (!result.status.toString().startsWith('2')) return result;
1687
+ return { ...result, body: validateActivityEnvelope(result.body, request.scope, { includeEvents: kind === 'timeline' || isExport, exportEnvelope: isExport }) };
1688
+ }
1689
+
1690
+ function managedDockerLaunchError(status, body) {
1691
+ const detail = String(body?.message ?? body?.error?.message ?? body?.error ?? body?.failure?.message ?? '');
1692
+ if (/refuses startup profiles that materialize raw credential refs/i.test(detail)) return {
1693
+ status: status >= 400 ? status : 422,
1694
+ body: {
1695
+ error: 'managed_docker_raw_credentials_rejected',
1696
+ message: 'Managed Docker does not accept startup profiles with raw credential references.',
1697
+ recovery: 'Use the sandbox credential proxy or select a VM runtime. Cockpit will not downgrade the transport automatically.',
1698
+ },
1699
+ };
1700
+ return { status, body };
1701
+ }
1702
+
1413
1703
  function defaultSessionLaunch(instance) {
1414
1704
  const runtime = String(instance?.runtime_posture?.kind ?? instance?.runtime ?? '').toLowerCase();
1415
1705
  if (runtime === 'host') {
@@ -2518,8 +2808,51 @@ export function createBridge({
2518
2808
  if (url.pathname === '/api/mcp/discovery' && req.method === 'GET') return json(res, 200, await getMcpDiscovery(upstreamUrl));
2519
2809
  if (url.pathname === '/api/mcp' && req.method === 'POST') return proxyMcpRequest(req, res, upstreamUrl, MCP_TOKEN_FILE);
2520
2810
  if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
2521
- if (url.pathname === '/api/missions') return json(res, 200, await getMissions(upstreamUrl));
2811
+ if (url.pathname === '/api/missions' && req.method === 'GET') return json(res, 200, await getMissions(upstreamUrl));
2812
+ if (url.pathname === '/api/missions' && req.method === 'POST') {
2813
+ const parsed = await readJsonBody(req);
2814
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2815
+ return json(res, 201, await dispatchMission(parsed.body, upstreamUrl));
2816
+ }
2522
2817
  if (url.pathname === '/api/events/snapshot') return json(res, 200, await getEventSnapshot(upstreamUrl));
2818
+ if (url.pathname === '/api/activity/coverage' && req.method === 'POST') {
2819
+ const parsed = await readJsonBody(req);
2820
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2821
+ try {
2822
+ const result = await activityProxy(upstreamUrl, 'coverage', parsed.body);
2823
+ await appendAudit('activity.coverage.queried', { scope: activityRequest(parsed.body).scope, complete: result.body?.completeness?.complete === true });
2824
+ return json(res, result.status, result.body);
2825
+ } catch (error) {
2826
+ return json(res, Number(error?.upstreamStatus) || (String(error?.code).startsWith('activity_') ? 400 : 502), { error: error?.code ?? 'activity_upstream_error', message: String(error?.message ?? error) });
2827
+ }
2828
+ }
2829
+ if (url.pathname === '/api/activity/timeline' && req.method === 'POST') {
2830
+ const parsed = await readJsonBody(req);
2831
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2832
+ try {
2833
+ const result = await activityProxy(upstreamUrl, 'timeline', parsed.body);
2834
+ if (result.status < 200 || result.status >= 300) return json(res, result.status, result.body);
2835
+ await appendAudit('activity.timeline.queried', { scope: activityRequest(parsed.body).scope, event_count: result.body.events.length, complete: result.body.completeness.complete });
2836
+ return json(res, result.status, result.body);
2837
+ } catch (error) {
2838
+ return json(res, Number(error?.upstreamStatus) || (String(error?.code).startsWith('activity_') ? 400 : 502), { error: error?.code ?? 'activity_upstream_error', message: String(error?.message ?? error) });
2839
+ }
2840
+ }
2841
+ if (url.pathname === '/api/activity/export' && req.method === 'POST') {
2842
+ const parsed = await readJsonBody(req);
2843
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2844
+ try {
2845
+ const result = await activityProxy(upstreamUrl, 'export', parsed.body);
2846
+ if (result.status === 503) return json(res, 503, { error: 'activity_export_unavailable', message: 'The sandbox signing key is unavailable.' });
2847
+ if (result.status < 200 || result.status >= 300) return json(res, result.status, result.body);
2848
+ await appendAudit('activity.export.completed', { scope: activityRequest(parsed.body).scope, key_id: result.body.manifest.key_id, merkle_root: result.body.manifest.merkle_root, event_count: result.body.manifest.event_count });
2849
+ res.setHeader('content-disposition', 'attachment; filename="activity-export.json"');
2850
+ res.setHeader('cache-control', 'no-store');
2851
+ return json(res, result.status, result.body);
2852
+ } catch (error) {
2853
+ return json(res, Number(error?.upstreamStatus) || (String(error?.code).startsWith('activity_') ? 400 : 502), { error: error?.code ?? 'activity_upstream_error', message: String(error?.message ?? error) });
2854
+ }
2855
+ }
2523
2856
  if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
2524
2857
  if (url.pathname === '/api/index/status' && req.method === 'GET') return json(res, 200, await getIndexStatus());
2525
2858
  if (url.pathname === '/api/index/query' && req.method === 'GET') {
@@ -2530,6 +2863,19 @@ export function createBridge({
2530
2863
  const result = await rebuildIndex(req);
2531
2864
  return json(res, result.status, result.body);
2532
2865
  }
2866
+ if (url.pathname === '/api/index/graphs' && req.method === 'POST') {
2867
+ const parsed = await readJsonBody(req);
2868
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2869
+ const requested = await appendAudit('index.graph.create.requested', { graph: parsed.body?.name ?? null });
2870
+ try {
2871
+ const graph = await createUserIndexGraph(parsed.body);
2872
+ await appendAudit('index.graph.create.completed', { request_ts: requested.ts, graph: graph.name });
2873
+ return json(res, 201, { ok: true, graph });
2874
+ } catch (error) {
2875
+ await appendAudit('index.graph.create.rejected', { request_ts: requested.ts, reason: String(error?.message ?? error) });
2876
+ return json(res, 400, { error: 'invalid_graph_definition', detail: String(error?.message ?? error) });
2877
+ }
2878
+ }
2533
2879
  if (url.pathname === '/api/audit' && req.method === 'GET') {
2534
2880
  const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
2535
2881
  return json(res, 200, { source: 'cockpit-bridge-audit/v1', audit: await readAudit({ limit }) });
@@ -2580,8 +2926,9 @@ export function createBridge({
2580
2926
  body: requestBody,
2581
2927
  },
2582
2928
  ]).catch((err) => ({ status: 502, body: { error: 'bridge_upstream_error', message: String(err?.message ?? err) } }));
2583
- await appendAudit('instance.launch.result', { request_ts: before.ts, status: result.status, result: result.body });
2584
- return json(res, result.status, result.body);
2929
+ const projected = managedDockerLaunchError(result.status, result.body);
2930
+ await appendAudit('instance.launch.result', { request_ts: before.ts, status: projected.status, result: projected.body });
2931
+ return json(res, projected.status, projected.body);
2585
2932
  }
2586
2933
  if ((m = url.pathname.match(/^\/api\/operations\/([^/]+)$/)) && req.method === 'GET') {
2587
2934
  return proxyFirst(res, [
@@ -2620,7 +2967,7 @@ export function createBridge({
2620
2967
  if (type && type !== 'all') {
2621
2968
  const types = type.split(',').map((t) => t.trim()).filter(Boolean);
2622
2969
  if (!types.length || types.some((t) => !CAPABILITY_TYPES.has(t))) {
2623
- return json(res, 400, { error: 'invalid_type', detail: 'type must be all, skill, agent, command, rule, flow, or a comma list of those kinds' });
2970
+ return json(res, 400, { error: 'invalid_type', detail: `type must be all or a comma list of: ${[...CAPABILITY_TYPES].join(', ')}` });
2624
2971
  }
2625
2972
  args.push('--type', types.join(','));
2626
2973
  }
@@ -2821,6 +3168,29 @@ export function createBridge({
2821
3168
  await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
2822
3169
  return json(res, status, body);
2823
3170
  }
3171
+ if ((m = url.pathname.match(/^\/api\/missions\/([^/]+)\/(pause|resume)$/)) && req.method === 'POST') {
3172
+ const parsed = await readJsonBody(req);
3173
+ if (parsed.error) return json(res, 400, { error: parsed.error });
3174
+ const result = await controlMission({
3175
+ action: m[2],
3176
+ sessionId: decodeURIComponent(m[1]),
3177
+ expectedUpdatedAt: parsed.body?.expected_updated_at,
3178
+ requestId: parsed.body?.request_id,
3179
+ });
3180
+ return json(res, 200, { ...result, projection: await getMissions(upstreamUrl) });
3181
+ }
3182
+ if ((m = url.pathname.match(/^\/api\/missions\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST') {
3183
+ const parsed = await readJsonBody(req);
3184
+ if (parsed.error) return json(res, 400, { error: parsed.error });
3185
+ const result = await controlMission({
3186
+ action: 'cancel',
3187
+ sessionId: decodeURIComponent(m[1]),
3188
+ missionId: decodeURIComponent(m[2]),
3189
+ expectedUpdatedAt: parsed.body?.expected_updated_at,
3190
+ requestId: parsed.body?.request_id,
3191
+ });
3192
+ return json(res, 200, { ...result, projection: await getMissions(upstreamUrl) });
3193
+ }
2824
3194
  if ((m = url.pathname.match(/^\/api\/tasks\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST') {
2825
3195
  await appendAudit('task.cancel.requested', { instance_id: decodeURIComponent(m[1]), task_id: decodeURIComponent(m[2]) });
2826
3196
  return proxy(res, 'POST', `${upstreamUrl}/agents/${encodeURIComponent(m[1])}/tasks/${encodeURIComponent(m[2])}:cancel`);
@@ -2867,7 +3237,7 @@ export function createBridge({
2867
3237
  }
2868
3238
  json(res, 404, { error: 'not_found', path: url.pathname });
2869
3239
  } catch (err) {
2870
- const status = Number(err?.upstreamStatus) || 502;
3240
+ const status = Number(err?.status) || Number(err?.upstreamStatus) || 502;
2871
3241
  json(res, status, { error: err?.code ?? 'bridge_upstream_error', message: String(err?.message ?? err) });
2872
3242
  }
2873
3243
  };
@@ -31,7 +31,7 @@
31
31
  "required": ["command"],
32
32
  "additionalProperties": false,
33
33
  "properties": {
34
- "command": { "type": "string", "description": "command/prompt injected into an agentic session" },
34
+ "command": { "type": "string", "pattern": "^/[a-zA-Z0-9][a-zA-Z0-9._-]*(\\s[^\\r\\n]*)?$", "description": "single slash command injected into an agentic session; newlines are forbidden" },
35
35
  "target": { "type": "string", "enum": ["focused", "new"], "default": "focused", "description": "focused = inject into the attached session, else offer a new one; new = always a fresh session" },
36
36
  "needs_args": { "type": "boolean", "description": "prompt for arguments before injecting" },
37
37
  "args_hint": { "type": "string" }
@@ -49,7 +49,7 @@
49
49
  "properties": {
50
50
  "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
51
51
  "title": { "type": "string" },
52
- "source": { "type": "string", "description": "url or path the screen iframe/region loads" }
52
+ "source": { "type": "string", "pattern": "^(cockpit|sandbox)://", "description": "first-party cockpit:// route or third-party sandbox://<contribution-id>/ resource" }
53
53
  }
54
54
  }
55
55
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.8.3",
3
+ "version": "2026.8.5",
4
4
  "description": "AIWG Cockpit — UX-first control plane over AIWG + multi-stack agentic sessions. Opt-in, separately published; NOT shipped in the base aiwg npm package (guarded by test/smoke/cockpit-base-footprint.test.js).",
5
5
  "type": "module",
6
6
  "license": "MIT",