@aiwg/cockpit 2026.7.21 → 2026.7.24

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.
@@ -29,6 +29,12 @@ const ALLOW_MOCK_EXECUTOR = process.env.AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR === '1'
29
29
  const AUTOSTART_EXECUTOR = process.env.AIWG_COCKPIT_AUTOSTART_EXECUTOR !== '0';
30
30
  const EXECUTOR_COMMAND = process.env.AIWG_COCKPIT_EXECUTOR_COMMAND ?? '';
31
31
  const EXECUTOR_TOKEN_FILE = process.env.AIWG_COCKPIT_EXECUTOR_TOKEN_FILE ?? '';
32
+ const MCP_TOKEN_FILE = process.env.AIWG_COCKPIT_MCP_TOKEN_FILE ?? '';
33
+ const LOCAL_DOCKER_FALLBACK = process.env.AIWG_COCKPIT_LOCAL_DOCKER_FALLBACK === '1';
34
+ const REQUIRE_SANDBOX_MTLS = process.env.AIWG_COCKPIT_REQUIRE_SANDBOX_MTLS === '1';
35
+ export function localLibvirtFallbackAllowed(platform = process.platform, envValue = process.env.AIWG_COCKPIT_LOCAL_LIBVIRT_FALLBACK) {
36
+ return platform === 'linux' || envValue === '1';
37
+ }
32
38
  const RUNTIME_DIR = join(homedir(), '.aiwg', 'cockpit', 'runtime');
33
39
  const auditDir = () => process.env.AIWG_COCKPIT_AUDIT_DIR || join(homedir(), '.aiwg', 'cockpit', 'audit');
34
40
  const auditLog = () => join(auditDir(), 'events.jsonl');
@@ -422,7 +428,11 @@ function isConnectionRefusedError(err) {
422
428
  }
423
429
 
424
430
  function rethrowExecutorSecurityError(err) {
425
- if ([401, 403].includes(Number(err?.upstreamStatus)) || String(err?.code ?? '').startsWith('executor_credential_')) throw err;
431
+ if (
432
+ [401, 403].includes(Number(err?.upstreamStatus)) ||
433
+ String(err?.code ?? '').startsWith('executor_credential_') ||
434
+ String(err?.code ?? '').startsWith('executor_trust_')
435
+ ) throw err;
426
436
  }
427
437
 
428
438
  export async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption, timeoutMs = 0 } = {}) {
@@ -507,10 +517,19 @@ async function getExecutorCapabilities(executorUrl) {
507
517
  const candidates = ['/healthz/deep', '/healthz', '/health'].map((path) => `${executorUrl}${path}`);
508
518
  try {
509
519
  const { target, body } = await fetchJsonFirst(candidates);
520
+ const runtimeProviders = await fetchJsonFirst([
521
+ `${executorUrl}/api/v2/admin/runtime/providers`,
522
+ `${executorUrl}/api/v2/runtime/providers`,
523
+ `${executorUrl}/admin/runtime/providers`,
524
+ `${executorUrl}/runtime/providers`,
525
+ ])
526
+ .then((result) => result.body)
527
+ .catch(() => undefined);
510
528
  return {
511
529
  status: 'ok',
512
530
  source: new URL(target).pathname,
513
531
  host_runtime_enabled: body.host_runtime_enabled === true || body.hostRuntimeEnabled === true,
532
+ runtime_providers: runtimeProviders && Array.isArray(runtimeProviders.providers) ? runtimeProviders : undefined,
514
533
  raw_status: body.status ?? body.state ?? 'unknown',
515
534
  };
516
535
  } catch (err) {
@@ -524,6 +543,120 @@ async function getExecutorCapabilities(executorUrl) {
524
543
  }
525
544
  }
526
545
 
546
+ async function getMcpDiscovery(executorUrl) {
547
+ try {
548
+ const { target, body } = await fetchJsonFirst([
549
+ `${executorUrl}/api/v2/admin/mcp/discovery`,
550
+ `${executorUrl}/admin/mcp/discovery`,
551
+ ]);
552
+ return {
553
+ source: executorUrl,
554
+ discovery_path: new URL(target).pathname,
555
+ fetched_at: new Date().toISOString(),
556
+ ...normalizeMcpDiscovery(body, executorUrl),
557
+ };
558
+ } catch (err) {
559
+ rethrowExecutorSecurityError(err);
560
+ return normalizeMcpDiscovery({
561
+ enabled: false,
562
+ status: 'disabled',
563
+ reason_code: 'mcp.discovery_unavailable',
564
+ error: String(err?.message ?? err),
565
+ }, executorUrl);
566
+ }
567
+ }
568
+
569
+ function normalizeMcpDiscovery(body, source) {
570
+ const endpoint = body?.endpoint && typeof body.endpoint === 'object' ? body.endpoint : {};
571
+ const auth = body?.auth && typeof body.auth === 'object' ? body.auth : {};
572
+ return {
573
+ source: source ?? body?.source,
574
+ enabled: body?.enabled === true,
575
+ status: body?.status ?? (body?.enabled === true ? 'enabled' : 'disabled'),
576
+ reason_code: body?.reason_code ?? body?.reasonCode ?? null,
577
+ error: body?.error,
578
+ endpoint: {
579
+ path: endpoint.path ?? '/mcp',
580
+ methods: Array.isArray(endpoint.methods) ? endpoint.methods : ['POST'],
581
+ transport: endpoint.transport ?? 'streamable-http',
582
+ stateless: endpoint.stateless !== false,
583
+ get_behavior: endpoint.get_behavior ?? endpoint.getBehavior ?? '405_method_not_allowed',
584
+ mcp_session_id: endpoint.mcp_session_id ?? endpoint.mcpSessionId ?? false,
585
+ },
586
+ protocol: body?.protocol ?? { latest: '2025-11-25', supported: [] },
587
+ auth: {
588
+ scheme: auth.scheme ?? 'bearer',
589
+ required: auth.required !== false,
590
+ principal_config: auth.principal_config ?? auth.principalConfig ?? 'mcp-principals.toml',
591
+ principals: Array.isArray(auth.principals) ? auth.principals.map((principal) => ({
592
+ client_id: principal.client_id ?? principal.clientId ?? '',
593
+ scopes: Array.isArray(principal.scopes) ? principal.scopes : [],
594
+ })).filter((principal) => principal.client_id) : [],
595
+ scopes: Array.isArray(auth.scopes) ? auth.scopes : [],
596
+ },
597
+ capabilities: body?.capabilities ?? {},
598
+ tools: Array.isArray(body?.tools) ? body.tools : [],
599
+ resources: Array.isArray(body?.resources) ? body.resources : [],
600
+ resource_templates: Array.isArray(body?.resource_templates)
601
+ ? body.resource_templates
602
+ : Array.isArray(body?.resourceTemplates) ? body.resourceTemplates : [],
603
+ errors: Array.isArray(body?.errors) ? body.errors : [],
604
+ notes: Array.isArray(body?.notes) ? body.notes : [],
605
+ };
606
+ }
607
+
608
+ async function proxyMcpRequest(req, res, executorUrl, mcpTokenFile) {
609
+ if (!mcpTokenFile) {
610
+ await appendAudit('sandbox.mcp.proxy', {
611
+ result: 'blocked',
612
+ reason: 'mcp_token_file_unconfigured',
613
+ });
614
+ return json(res, 503, {
615
+ error: 'mcp_token_file_unconfigured',
616
+ message: 'Bridge MCP proxy requires AIWG_COCKPIT_MCP_TOKEN_FILE.',
617
+ });
618
+ }
619
+ const parsed = await readJsonBody(req);
620
+ if (parsed.error) return json(res, 400, { error: parsed.error });
621
+ const body = parsed.body || {};
622
+ const rpcMethod = typeof body.method === 'string' ? body.method : 'unknown';
623
+ const target = `${executorUrl}/mcp`;
624
+ let status = 502;
625
+ try {
626
+ const token = await resolveExecutorBearer(mcpTokenFile);
627
+ const headers = {
628
+ authorization: `Bearer ${token}`,
629
+ 'content-type': 'application/json',
630
+ accept: 'application/json, text/event-stream',
631
+ };
632
+ const protocolVersion = req.headers['mcp-protocol-version'];
633
+ if (typeof protocolVersion === 'string' && protocolVersion.trim()) {
634
+ headers['mcp-protocol-version'] = protocolVersion.trim();
635
+ }
636
+ const response = await fetch(target, {
637
+ method: 'POST',
638
+ headers,
639
+ body: JSON.stringify(body),
640
+ });
641
+ status = response.status;
642
+ const responseBody = await response.json().catch(() => ({}));
643
+ await appendAudit('sandbox.mcp.proxy', {
644
+ result: response.ok ? 'ok' : 'error',
645
+ method: rpcMethod,
646
+ status,
647
+ });
648
+ return json(res, status, responseBody);
649
+ } catch (err) {
650
+ await appendAudit('sandbox.mcp.proxy', {
651
+ result: 'error',
652
+ method: rpcMethod,
653
+ status,
654
+ error: String(err?.code ?? err?.message ?? err),
655
+ });
656
+ throw err;
657
+ }
658
+ }
659
+
527
660
  function defaultExecutorCommand() {
528
661
  if (EXECUTOR_COMMAND) return EXECUTOR_COMMAND.split(/\s+/).filter(Boolean);
529
662
  const candidates = [
@@ -580,6 +713,110 @@ async function proxyFirst(res, candidates, options) {
580
713
  }
581
714
  }
582
715
 
716
+ function normalizedInstanceName(value, fallback = 'cockpit-fast-start') {
717
+ const cleaned = String(value || fallback)
718
+ .toLowerCase()
719
+ .replace(/[^a-z0-9-]/g, '-')
720
+ .replace(/-+$/g, '')
721
+ .slice(0, 63);
722
+ const prefixed = /^[a-z]/.test(cleaned) ? cleaned : `a-${cleaned.replace(/^-+/, '')}`;
723
+ return prefixed && prefixed.length >= 2 ? prefixed.slice(0, 63) : fallback;
724
+ }
725
+
726
+ function instanceVmName(instance, instanceId) {
727
+ return instance?.launch_context?.name
728
+ ?? instance?.launchContext?.name
729
+ ?? instance?.name
730
+ ?? instance?.id
731
+ ?? instanceId;
732
+ }
733
+
734
+ function defaultAssetRef(instanceId, action) {
735
+ return `${normalizedInstanceName(instanceId, 'cockpit-vm')}-${action}-${Date.now().toString(36)}`.slice(0, 96);
736
+ }
737
+
738
+ function upstreamBridgeError(err) {
739
+ rethrowExecutorSecurityError(err);
740
+ return { status: 502, body: { error: 'bridge_upstream_error', message: String(err?.message ?? err) } };
741
+ }
742
+
743
+ async function providerFastStartAction(upstreamUrl, instanceId, action, body = {}) {
744
+ let inventory;
745
+ try { inventory = await getInventory(upstreamUrl); }
746
+ catch (err) { rethrowExecutorSecurityError(err); inventory = { instances: [] }; }
747
+ const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
748
+ if (!inst) return { status: 404, body: { error: 'instance_not_found', instance_id: instanceId } };
749
+ const runtime = String(inst.runtime_posture?.kind ?? inst.runtime ?? '').toLowerCase();
750
+ if (!['vm', 'qemu', 'kvm'].includes(runtime)) {
751
+ return { status: 422, body: { error: 'unsupported_runtime', message: 'fast-start actions are valid only for VM instances' } };
752
+ }
753
+ const provider = String(inst.provider ?? '').trim();
754
+ if (!provider) return { status: 422, body: { error: 'provider_required', message: 'executor inventory did not report an effective VM provider' } };
755
+
756
+ const vmName = instanceVmName(inst, instanceId);
757
+ const rawAsset = body.asset_ref ?? body.assetRef ?? body.snapshot_id ?? body.snapshotId ?? body.checkpoint_id ?? body.checkpointId ?? body.pool;
758
+ const assetRef = String(rawAsset ?? '').trim();
759
+ const restoreMode = String(body.restore_mode ?? body.restoreMode ?? 'ondemand').trim() || 'ondemand';
760
+ const childName = normalizedInstanceName(
761
+ body.name ?? body.child_name ?? body.childName,
762
+ `${normalizedInstanceName(vmName, 'cockpit-vm')}-${action === 'warm-pool' ? 'warm' : action}`,
763
+ );
764
+
765
+ if (action === 'snapshot' || action === 'checkpoint') {
766
+ const newAssetRef = assetRef || defaultAssetRef(vmName, provider === 'libvirt' ? 'checkpoint' : 'snapshot');
767
+ if (provider === 'cloud-hypervisor') {
768
+ return fetchJsonFirst([{
769
+ target: `${upstreamUrl}/api/v2/admin/cloud-hypervisor/snapshots`,
770
+ method: 'POST',
771
+ headers: { 'content-type': 'application/json' },
772
+ body: JSON.stringify({
773
+ vm: vmName,
774
+ snapshot_id: newAssetRef,
775
+ pre_enrollment: body.pre_enrollment ?? body.preEnrollment ?? true,
776
+ }),
777
+ }]).catch(upstreamBridgeError);
778
+ }
779
+ if (provider === 'libvirt') {
780
+ return fetchJsonFirst([{
781
+ target: `${upstreamUrl}/api/v2/admin/libvirt/checkpoints`,
782
+ method: 'POST',
783
+ headers: { 'content-type': 'application/json' },
784
+ body: JSON.stringify({
785
+ vm: vmName,
786
+ checkpoint_id: newAssetRef,
787
+ pre_enrollment: true,
788
+ }),
789
+ }]).catch(upstreamBridgeError);
790
+ }
791
+ return { status: 422, body: { error: 'unsupported_provider_action', provider, action } };
792
+ }
793
+
794
+ if (!assetRef) {
795
+ return { status: 400, body: { error: 'asset_ref_required', message: 'restore, fork, and warm-pool actions require an opaque asset_ref' } };
796
+ }
797
+ const mode = action === 'warm-pool' ? 'warm_pool' : action;
798
+ return fetchJsonFirst([{
799
+ target: `${upstreamUrl}/api/v2/admin/instances`,
800
+ method: 'POST',
801
+ headers: { 'content-type': 'application/json' },
802
+ body: JSON.stringify({
803
+ name: childName,
804
+ runtime: 'qemu',
805
+ provider,
806
+ runtime_options: {
807
+ kind: 'vm',
808
+ provider,
809
+ launch_strategy: {
810
+ mode,
811
+ prefer_fast_start: true,
812
+ asset_ref: assetRef,
813
+ ...(provider === 'cloud-hypervisor' ? { restore_mode: restoreMode } : {}),
814
+ },
815
+ },
816
+ }),
817
+ }]).catch(upstreamBridgeError);
818
+ }
819
+
583
820
  async function destroyInstance(upstreamUrl, instanceId) {
584
821
  let inventory;
585
822
  try { inventory = await getInventory(upstreamUrl); }
@@ -596,7 +833,7 @@ async function destroyInstance(upstreamUrl, instanceId) {
596
833
  try {
597
834
  const result = await fetchJsonFirst(candidates);
598
835
  if (result.status < 400) {
599
- if (['docker', 'container'].includes(runtime) && dockerName) {
836
+ if (LOCAL_DOCKER_FALLBACK && ['docker', 'container'].includes(runtime) && dockerName) {
600
837
  try {
601
838
  await spawnCollect('docker', ['rm', '-f', dockerName]);
602
839
  return {
@@ -648,6 +885,18 @@ async function destroyInstance(upstreamUrl, instanceId) {
648
885
  body: { error: 'instance_not_destroyable', message: `No destroyable runtime record for ${instanceId}` },
649
886
  };
650
887
  }
888
+ if (!LOCAL_DOCKER_FALLBACK) {
889
+ return {
890
+ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/destroy`,
891
+ status: 409,
892
+ body: {
893
+ error: 'local_docker_fallback_disabled',
894
+ message: 'Sandbox management did not accept this destroy request. Local docker rm fallback is disabled unless AIWG_COCKPIT_LOCAL_DOCKER_FALLBACK=1 is set for local development.',
895
+ runtime,
896
+ docker_name: dockerName,
897
+ },
898
+ };
899
+ }
651
900
 
652
901
  let alreadyGone = false;
653
902
  try {
@@ -730,6 +979,18 @@ async function reconnectInstance(upstreamUrl, instanceId) {
730
979
  }
731
980
 
732
981
  if (['docker', 'container'].includes(runtime) && dockerName) {
982
+ if (!LOCAL_DOCKER_FALLBACK) {
983
+ return {
984
+ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`,
985
+ status: 409,
986
+ body: {
987
+ error: 'local_docker_fallback_disabled',
988
+ message: 'Sandbox management did not accept this reconnect request. Local docker exec fallback is disabled unless AIWG_COCKPIT_LOCAL_DOCKER_FALLBACK=1 is set for local development.',
989
+ runtime,
990
+ docker_name: dockerName,
991
+ },
992
+ };
993
+ }
733
994
  try {
734
995
  const output = await spawnCollect('docker', ['exec', dockerName, 'agent-reconnect']);
735
996
  return {
@@ -759,6 +1020,19 @@ async function reconnectInstance(upstreamUrl, instanceId) {
759
1020
  }
760
1021
 
761
1022
  if (VM_RUNTIME_KINDS.includes(runtime)) {
1023
+ if (!localLibvirtFallbackAllowed()) {
1024
+ return {
1025
+ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`,
1026
+ status: 409,
1027
+ body: {
1028
+ error: 'local_libvirt_fallback_disabled',
1029
+ message: 'Sandbox management did not accept this reconnect request. Local virsh fallback is only automatic on Linux; set AIWG_COCKPIT_LOCAL_LIBVIRT_FALLBACK=1 for explicit local development on this host.',
1030
+ runtime,
1031
+ platform: process.platform,
1032
+ arch: process.arch,
1033
+ },
1034
+ };
1035
+ }
762
1036
  // For VM instances the agent_id doubles as the libvirt domain name
763
1037
  // (agentic-sandbox provision-vm.sh registers agent_id = $vm_name).
764
1038
  const domain = dockerName ?? inst?.name ?? String(instanceId);
@@ -933,6 +1207,109 @@ function normalizeTransport(posture) {
933
1207
  };
934
1208
  }
935
1209
 
1210
+ function safeRef(value) {
1211
+ const ref = typeof value === 'string' ? value.trim() : '';
1212
+ if (!ref) return undefined;
1213
+ if (/-----BEGIN|PRIVATE KEY|TOKEN|SECRET|PASSWORD|[\r\n]/i.test(ref)) return '[redacted]';
1214
+ return ref.slice(0, 160);
1215
+ }
1216
+
1217
+ function normalizeBootstrapTrustReadiness(body, executorUrl, { available = true } = {}) {
1218
+ const ca = body?.ca_provider && typeof body.ca_provider === 'object' ? body.ca_provider : {};
1219
+ const bootstrap = body?.bootstrap && typeof body.bootstrap === 'object' ? body.bootstrap : {};
1220
+ const status = String(body?.status ?? (available ? 'unknown' : 'disabled')).toLowerCase();
1221
+ const normalizedStatus = ['secure', 'degraded', 'disabled'].includes(status)
1222
+ ? status
1223
+ : (ca.configured === true || ca.available === true ? 'degraded' : 'disabled');
1224
+ const trustFresh = ca.trust_bundle_fresh ?? ca.trustBundleFresh ?? ca.fresh;
1225
+ const tokenStoreConfigured = bootstrap.token_store_configured ?? bootstrap.tokenStoreConfigured;
1226
+ const caConfigured = ca.configured ?? ca.available;
1227
+ const missing = [];
1228
+ if (caConfigured === false) missing.push('ca_provider');
1229
+ if (tokenStoreConfigured === false) missing.push('bootstrap_token_store');
1230
+ if (trustFresh === false) missing.push('fresh_trust_bundle');
1231
+ const plaintextDev = new URL(executorUrl).protocol === 'http:' && isLocalHostName(new URL(executorUrl).hostname);
1232
+ const recovery = normalizedStatus === 'secure'
1233
+ ? 'Sandbox CA and bootstrap trust are ready.'
1234
+ : normalizedStatus === 'degraded'
1235
+ ? 'Refresh sandbox CA/bootstrap readiness, rotate stale trust material, then reload Cockpit.'
1236
+ : plaintextDev
1237
+ ? 'Plaintext local development mode only; enable sandbox mTLS before using remote or shared executors.'
1238
+ : 'Configure sandbox CA provider and client trust refs before connecting Cockpit.';
1239
+ return {
1240
+ status: normalizedStatus,
1241
+ mode: normalizedStatus === 'secure' ? 'mtls' : (plaintextDev ? 'plaintext-dev' : 'disabled'),
1242
+ label: normalizedStatus === 'secure'
1243
+ ? 'Sandbox mTLS ready'
1244
+ : normalizedStatus === 'degraded'
1245
+ ? 'Sandbox trust degraded'
1246
+ : (plaintextDev ? 'Plaintext dev mode' : 'Sandbox trust disabled'),
1247
+ source: body?.source ?? '/api/v2/admin/bootstrap/readiness',
1248
+ ca_provider_ref: safeRef(ca.provider_ref ?? ca.providerRef ?? ca.provider ?? ca.id ?? ca.name),
1249
+ trust_bundle_ref: safeRef(ca.trust_bundle_ref ?? ca.trustBundleRef ?? ca.bundle_ref ?? ca.bundleRef),
1250
+ client_identity_ref: safeRef(ca.client_identity_ref ?? ca.clientIdentityRef ?? ca.identity_ref ?? ca.identityRef),
1251
+ rotation_state: safeRef(ca.rotation_state ?? ca.rotationState ?? ca.state),
1252
+ expires_at: safeRef(ca.expires_at ?? ca.expiresAt ?? ca.not_after ?? ca.notAfter),
1253
+ trust_bundle_fresh: trustFresh === undefined ? undefined : Boolean(trustFresh),
1254
+ token_store_configured: tokenStoreConfigured === undefined ? undefined : Boolean(tokenStoreConfigured),
1255
+ missing_required_material: missing,
1256
+ recovery,
1257
+ };
1258
+ }
1259
+
1260
+ function assertRequiredBootstrapTrust(posture) {
1261
+ if (posture.status === 'secure' && posture.missing_required_material.length === 0) return;
1262
+ const err = executorAuthError(
1263
+ 'executor_trust_required',
1264
+ `sandbox mTLS is required but bootstrap trust is ${posture.status}: ${posture.recovery}`,
1265
+ );
1266
+ err.upstreamStatus = 503;
1267
+ err.recovery = posture.recovery;
1268
+ throw err;
1269
+ }
1270
+
1271
+ async function getBootstrapTrustPosture(executorUrl, { requireSandboxMtls = false } = {}) {
1272
+ try {
1273
+ const { target, body } = await fetchJsonFirst([
1274
+ `${executorUrl}/api/v2/admin/bootstrap/readiness`,
1275
+ `${executorUrl}/admin/bootstrap/readiness`,
1276
+ ]);
1277
+ const posture = normalizeBootstrapTrustReadiness({ ...body, source: new URL(target).pathname }, executorUrl);
1278
+ if (requireSandboxMtls) assertRequiredBootstrapTrust(posture);
1279
+ return posture;
1280
+ } catch (err) {
1281
+ rethrowExecutorSecurityError(err);
1282
+ const posture = normalizeBootstrapTrustReadiness({
1283
+ status: 'disabled',
1284
+ source: '/api/v2/admin/bootstrap/readiness',
1285
+ ca_provider: { configured: false },
1286
+ bootstrap: { token_store_configured: false },
1287
+ }, executorUrl, { available: false });
1288
+ if (requireSandboxMtls) assertRequiredBootstrapTrust(posture);
1289
+ return posture;
1290
+ }
1291
+ }
1292
+
1293
+ function normalizeStoragePosture(posture) {
1294
+ const raw = posture && typeof posture === 'object' ? posture : {};
1295
+ return {
1296
+ persistent: Boolean(raw.persistent ?? raw.persists ?? raw.persistence === 'persistent'),
1297
+ delete_on_destroy: Boolean(raw.delete_on_destroy ?? raw.deleteOnDestroy),
1298
+ scope: raw.scope ?? raw.storage_scope ?? raw.storageScope,
1299
+ reason: raw.reason ?? raw.detail,
1300
+ };
1301
+ }
1302
+
1303
+ function normalizeLifecycle(lifecycle) {
1304
+ const raw = lifecycle && typeof lifecycle === 'object' ? lifecycle : {};
1305
+ return {
1306
+ destroy: raw.destroy ?? raw.delete ?? raw.remove,
1307
+ reconnect: raw.reconnect,
1308
+ start: raw.start,
1309
+ stop: raw.stop,
1310
+ };
1311
+ }
1312
+
936
1313
  function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agentReady = false) {
937
1314
  const list = Array.isArray(backends) ? backends : [];
938
1315
  if (!list.length && runtimeKind === 'host') {
@@ -984,6 +1361,10 @@ function normalizeInstance(executorUrl, i) {
984
1361
  return {
985
1362
  id,
986
1363
  runtime,
1364
+ provider: i.provider ?? i.runtime_provider ?? i.runtimeProvider ?? i.runtime?.provider,
1365
+ capabilities: Array.isArray(i.capabilities) ? i.capabilities : i.runtime?.capabilities,
1366
+ capability_constraints: i.capability_constraints ?? i.capabilityConstraints ?? i.runtime?.capability_constraints ?? i.runtime?.capabilityConstraints,
1367
+ gpu: i.gpu ?? i.gpu_posture ?? i.gpuPosture ?? i.runtime?.gpu,
987
1368
  loadout,
988
1369
  state: i.state ?? i.status ?? 'unknown',
989
1370
  tenant: i.tenant_id ?? i.tenant ?? i.tenantId ?? 'default',
@@ -1005,6 +1386,8 @@ function normalizeInstance(executorUrl, i) {
1005
1386
  image_ref: i.image_ref ?? i.imageRef ?? i.runtime_extension?.image_ref ?? i.runtimeExtension?.imageRef,
1006
1387
  source: i.runtime_extension ? 'agent-card runtime extension' : i.launch_context?.source ?? i.launchContext?.source,
1007
1388
  },
1389
+ storage: normalizeStoragePosture(i.storage ?? i.storage_posture ?? i.storagePosture ?? i.lifecycle?.storage),
1390
+ lifecycle: normalizeLifecycle(i.lifecycle ?? i.lifecycle_support ?? i.lifecycleSupport),
1008
1391
  agent_ready: agentReady,
1009
1392
  registered_agent_id: i.registered_agent_id ?? i.registeredAgentId,
1010
1393
  session_backends: normalizeSessionBackends(i.session_backends ?? i.sessionBackends ?? i.session_host?.backends ?? i.sessionHost?.backends ?? i.capabilities?.session_backends ?? i.capabilities?.sessionBackends, runtimePosture.kind, i.state ?? i.status, agentReady),
@@ -1154,7 +1537,8 @@ async function getAgentBackedHostInventory(executorUrl, degradedDetail) {
1154
1537
  }
1155
1538
 
1156
1539
  /** Normalize the executor's admin inventory into the Bridge's UI shape. */
1157
- async function getInventory(executorUrl) {
1540
+ async function getInventory(executorUrl, { requireSandboxMtls = false } = {}) {
1541
+ const bootstrapTrust = await getBootstrapTrustPosture(executorUrl, { requireSandboxMtls });
1158
1542
  const { target, status, body } = await fetchJsonFirst([
1159
1543
  `${executorUrl}/admin/instances`,
1160
1544
  `${executorUrl}/api/v2/admin/instances`,
@@ -1176,6 +1560,7 @@ async function getInventory(executorUrl) {
1176
1560
  count: 0,
1177
1561
  degraded_admin_inventory: detail,
1178
1562
  admin_error: body,
1563
+ bootstrap_trust: bootstrapTrust,
1179
1564
  instances: [],
1180
1565
  };
1181
1566
  }
@@ -1190,6 +1575,8 @@ async function getInventory(executorUrl) {
1190
1575
  admin_path: new URL(target).pathname,
1191
1576
  fetched_at: new Date().toISOString(),
1192
1577
  count: normalized.length,
1578
+ bootstrap_trust: bootstrapTrust,
1579
+ degraded_providers: body?.degraded_providers,
1193
1580
  instances: normalized,
1194
1581
  };
1195
1582
  }
@@ -1240,6 +1627,8 @@ async function getLoadouts(executorUrl) {
1240
1627
  label: l.label ?? l.display_name ?? l.displayName ?? id,
1241
1628
  description: l.description ?? l.summary,
1242
1629
  runtimes: l.runtimes ?? l.runtime_kinds ?? l.supported_runtimes,
1630
+ runtime_options: l.runtime_options ?? l.runtimeOptions,
1631
+ compatibility: l.compatibility,
1243
1632
  };
1244
1633
  }).filter((l) => l.id);
1245
1634
  return { source: executorUrl, loadouts_path: new URL(target).pathname, count: loadouts.length, loadouts };
@@ -1854,6 +2243,7 @@ export function createBridge({
1854
2243
  allowMockExecutor = ALLOW_MOCK_EXECUTOR,
1855
2244
  token,
1856
2245
  executorTokenFile = EXECUTOR_TOKEN_FILE,
2246
+ requireSandboxMtls = REQUIRE_SANDBOX_MTLS,
1857
2247
  } = {}) {
1858
2248
  const upstreamUrl = executorUrl;
1859
2249
  const TOKEN = token ?? randomBytes(24).toString('hex');
@@ -1891,8 +2281,11 @@ export function createBridge({
1891
2281
  if (url.pathname.startsWith('/api/')) {
1892
2282
  try {
1893
2283
  await assertRealExecutor(upstreamUrl, allowMockExecutor);
2284
+ if (requireSandboxMtls) {
2285
+ await getBootstrapTrustPosture(upstreamUrl, { requireSandboxMtls: true });
2286
+ }
1894
2287
  } catch (err) {
1895
- return json(res, 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err) });
2288
+ return json(res, Number(err?.upstreamStatus) || 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err), recovery: err?.recovery });
1896
2289
  }
1897
2290
  }
1898
2291
  if (url.pathname === '/api/events' && req.method === 'GET') {
@@ -1910,8 +2303,13 @@ export function createBridge({
1910
2303
  req.on('close', () => clearInterval(timer));
1911
2304
  return;
1912
2305
  }
1913
- if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
2306
+ if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl, { requireSandboxMtls }));
1914
2307
  if (url.pathname === '/api/executor/capabilities') return json(res, 200, await getExecutorCapabilities(upstreamUrl));
2308
+ if (url.pathname === '/api/bootstrap/readiness' && req.method === 'GET') {
2309
+ return json(res, 200, await getBootstrapTrustPosture(upstreamUrl, { requireSandboxMtls }));
2310
+ }
2311
+ if (url.pathname === '/api/mcp/discovery' && req.method === 'GET') return json(res, 200, await getMcpDiscovery(upstreamUrl));
2312
+ if (url.pathname === '/api/mcp' && req.method === 'POST') return proxyMcpRequest(req, res, upstreamUrl, MCP_TOKEN_FILE);
1915
2313
  if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
1916
2314
  if (url.pathname === '/api/missions') return json(res, 200, await getMissions(upstreamUrl));
1917
2315
  if (url.pathname === '/api/events/snapshot') return json(res, 200, await getEventSnapshot(upstreamUrl));
@@ -2175,6 +2573,29 @@ export function createBridge({
2175
2573
  }
2176
2574
 
2177
2575
  // --- management surface (UC-012): lifecycle + task cancel ---
2576
+ if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(snapshot|checkpoint|restore|fork|warm-pool)$/)) && req.method === 'POST') {
2577
+ const parsed = await readJsonBody(req);
2578
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2579
+ const instanceId = decodeURIComponent(m[1]);
2580
+ const action = m[2];
2581
+ const body = parsed.body || {};
2582
+ const before = await appendAudit('instance.fast_start.requested', {
2583
+ instance_id: instanceId,
2584
+ action,
2585
+ asset_ref: body.asset_ref ?? body.assetRef ?? body.snapshot_id ?? body.snapshotId ?? body.checkpoint_id ?? body.checkpointId ?? body.pool,
2586
+ name: body.name ?? body.child_name ?? body.childName,
2587
+ });
2588
+ const result = await providerFastStartAction(upstreamUrl, instanceId, action, body);
2589
+ await appendAudit('instance.fast_start.accepted', {
2590
+ request_ts: before.ts,
2591
+ instance_id: instanceId,
2592
+ action,
2593
+ status: result.status,
2594
+ operation_id: result.body?.id ?? result.body?.operation?.id,
2595
+ result: result.body,
2596
+ });
2597
+ return json(res, result.status, result.body);
2598
+ }
2178
2599
  if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop)$/)) && req.method === 'POST') {
2179
2600
  const result = await fetchJsonFirst([
2180
2601
  `${upstreamUrl}/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
@@ -2,8 +2,8 @@
2
2
  // Self-contained (own ports); no deps. Exits non-zero on failure.
3
3
  import assert from 'node:assert/strict';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { createExecutor } from '../../mock-executor/src/server.mjs';
6
- import { createBridge, normalizeSessionRows } from './server.mjs';
5
+ import { createExecutor, DEFAULT_INSTANCE } from '../../mock-executor/src/server.mjs';
6
+ import { createBridge, localLibvirtFallbackAllowed, normalizeSessionRows } from './server.mjs';
7
7
 
8
8
  const mock = createExecutor();
9
9
  await new Promise((r) => mock.listen(0, '127.0.0.1', r));
@@ -34,6 +34,8 @@ try {
34
34
  assert.equal(inv.instances.find((i) => i.transport?.mode === 'shared-secret')?.transport.trust, 'compatibility', 'legacy secret transport is compatibility posture');
35
35
  const i0 = inv.instances[0];
36
36
  for (const k of ['id', 'runtime', 'loadout', 'state', 'tenant', 'card_url', 'runtime_posture', 'host_daemon', 'transport', 'launch_context', 'session_backends']) assert.ok(k in i0, `field ${k}`);
37
+ assert.equal(i0.storage?.persistent, true, 'storage persistence surfaced');
38
+ assert.equal(i0.storage?.delete_on_destroy, true, 'storage delete-on-destroy surfaced');
37
39
  assert.ok(['vm', 'container', 'host', 'wasm-edge'].includes(i0.runtime), 'runtime kind');
38
40
 
39
41
  // A transient executor outage must not poison Bridge state or require a
@@ -101,6 +103,45 @@ try {
101
103
  assert.ok(Array.isArray(lo.loadouts) && lo.loadouts.length >= 3, 'loadout catalog returned');
102
104
  assert.ok(lo.loadouts.every((l) => typeof l.id === 'string' && typeof l.label === 'string'), 'loadouts carry id+label');
103
105
  assert.ok(lo.loadouts.some((l) => l.id === 'security-audit'), 'catalog includes a non-default loadout');
106
+ const gpuLoadout = lo.loadouts.find((l) => l.id === 'gpu-vfio');
107
+ assert.ok(gpuLoadout?.runtime_options?.required_capabilities?.includes('device.vfio'), 'loadout runtime_options preserve VFIO requirement');
108
+ assert.ok(gpuLoadout?.compatibility?.[0]?.excluded_capabilities?.includes('instance.restore'), 'loadout compatibility preserves fast-start exclusion');
109
+
110
+ const caps = await (await f('/api/executor/capabilities')).json();
111
+ assert.ok(caps.runtime_providers?.providers?.some((p) => p.provider === 'cloud-hypervisor'), 'runtime providers discovered');
112
+ assert.ok(caps.runtime_providers.providers.find((p) => p.provider === 'cloud-hypervisor')?.capability_constraints?.[0]?.excludes?.includes('instance.restore'), 'provider VFIO constraint preserved');
113
+ const hostProvider = caps.runtime_providers.providers.find((p) => p.provider === 'host');
114
+ const dockerProvider = caps.runtime_providers.providers.find((p) => p.provider === 'docker');
115
+ assert.ok(hostProvider?.platforms?.includes('darwin/arm64'), 'Apple Silicon host runtime discovery is proxied');
116
+ assert.equal(hostProvider?.posture?.host_architecture, 'arm64', 'Apple Silicon host architecture is preserved');
117
+ assert.equal(dockerProvider?.engine, 'Docker Desktop', 'Docker Desktop runtime posture is proxied');
118
+ assert.equal(dockerProvider?.posture?.host_platform, 'darwin', 'Docker Desktop host platform is preserved');
119
+ assert.equal(localLibvirtFallbackAllowed('darwin', undefined), false, 'virsh fallback is not automatic on macOS');
120
+ assert.equal(localLibvirtFallbackAllowed('darwin', '1'), true, 'virsh fallback can be explicitly enabled for local development');
121
+ assert.equal(localLibvirtFallbackAllowed('linux', undefined), true, 'Linux bridge hosts retain local virsh fallback');
122
+
123
+ const vm = inv.instances.find((i) => i.provider === 'cloud-hypervisor');
124
+ assert.ok(vm, 'provider-aware VM inventory row present');
125
+ const fastStartAccepted = await (await f(`/api/instances/${encodeURIComponent(vm.id)}/snapshot`, {
126
+ method: 'POST',
127
+ headers: { 'content-type': 'application/json' },
128
+ body: JSON.stringify({ asset_ref: 'cockpit-smoke-snapshot' }),
129
+ })).json();
130
+ assert.ok(fastStartAccepted.id, 'fast-start proxy returns operation id');
131
+ const fastStartTerminal = await (await f(`/api/operations/${encodeURIComponent(fastStartAccepted.id)}`)).json();
132
+ assert.equal(fastStartTerminal.state, 'succeeded', 'fast-start operation reaches terminal state');
133
+ assert.equal(fastStartTerminal.result.provider, 'cloud-hypervisor', 'fast-start operation preserves provider');
134
+
135
+ const mcp = await (await f('/api/mcp/discovery')).json();
136
+ assert.equal(mcp.enabled, true, 'MCP discovery enabled');
137
+ assert.equal(mcp.endpoint?.path, '/mcp', 'MCP endpoint path surfaced');
138
+ assert.equal(mcp.endpoint?.mcp_session_id, false, 'MCP discovery is stateless/no session id');
139
+ assert.ok(mcp.tools.some((tool) => tool.name === 'list_sandboxes'), 'MCP tools surfaced');
140
+ assert.ok(mcp.resource_templates.some((template) => template.uriTemplate === 'sandbox://sessions/{session_id}/screen'), 'MCP resource templates surfaced');
141
+ assert.equal((await f('/api/mcp', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) })).status, 503, 'MCP proxy fail-closed without token file');
142
+ const gatedReconnect = await f(`/api/instances/${DEFAULT_INSTANCE}/reconnect`, { method: 'POST' });
143
+ assert.equal(gatedReconnect.status, 409, 'Docker reconnect fallback is gated unless local dev flag is set');
144
+ assert.equal((await gatedReconnect.json()).error, 'local_docker_fallback_disabled', 'Docker reconnect fallback error is explicit');
104
145
 
105
146
  // registry binding: discover + show through the aiwg CLI (#1592)
106
147
  const cap = await (await f("/api/capabilities?q=" + encodeURIComponent("deploy production") + "&limit=4")).json();
@@ -196,7 +237,7 @@ try {
196
237
  assert.equal((await fetch(base + asset[1].replace(/^\.\//, '/'))).status, 200, 'built React bundle served');
197
238
  }
198
239
 
199
- console.log(`SMOKE OK — inventory(4) + running(${run.count}) + sessions(demo-shell) + registry(discover→${cap.results.length}) + contrib(${contrib.actions.length}) + shell(${shell})`);
240
+ console.log(`SMOKE OK — inventory(4) + running(${run.count}) + sessions(demo-shell) + mcp(${mcp.tools.length} tools) + registry(discover→${cap.results.length}) + contrib(${contrib.actions.length}) + shell(${shell})`);
200
241
  } finally {
201
242
  bridge.close();
202
243
  mock.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.7.21",
3
+ "version": "2026.7.24",
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",