@aiwg/cockpit 2026.7.23 → 2026.7.25
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/README.md +46 -7
- package/bridge/src/server.mjs +431 -6
- package/bridge/src/smoke.mjs +44 -3
- package/package.json +1 -1
- package/web/dist/assets/index-BTBL-9AY.js +312 -0
- package/web/dist/assets/index-CesGeDud.css +32 -0
- package/web/dist/index.html +2 -2
- package/web/src/App.test.tsx +34 -1
- package/web/src/components/Inventory.test.tsx +165 -0
- package/web/src/components/Inventory.tsx +176 -3
- package/web/src/components/LaunchInstanceModal.test.tsx +151 -1
- package/web/src/components/LaunchInstanceModal.tsx +193 -3
- package/web/src/components/Telemetry.tsx +17 -2
- package/web/src/runtimeContracts.fixtures.ts +230 -0
- package/web/src/runtimeContracts.test.ts +63 -0
- package/web/src/styles.css +7 -0
- package/web/src/types.ts +168 -2
- package/web/dist/assets/index-B0ea5aQ1.css +0 -32
- package/web/dist/assets/index-CYVukAJR.js +0 -312
package/README.md
CHANGED
|
@@ -275,6 +275,33 @@ QEMU/VM launches. Cockpit does not replace attached sessions when provisioning;
|
|
|
275
275
|
it refreshes inventory and leaves concurrency and resource admission to
|
|
276
276
|
agentic-sandbox.
|
|
277
277
|
|
|
278
|
+
For VM rows, Inventory renders provider-aware fast-start actions only when the
|
|
279
|
+
executor advertises the matching instance capability. Cloud Hypervisor rows can
|
|
280
|
+
offer snapshot, restore, fork, and warm-pool handoff. Libvirt rows can offer
|
|
281
|
+
checkpoint, restore, and warm-pool handoff when advertised. Cockpit sends
|
|
282
|
+
restore, fork, and warm-pool requests through the unified sandbox
|
|
283
|
+
`runtime_options` launch intent, polls the async operation resource to terminal
|
|
284
|
+
state, and records request/terminal audit evidence through the Bridge. VFIO
|
|
285
|
+
constraint exclusions keep unsafe fast-start actions disabled with the
|
|
286
|
+
executor-provided reason.
|
|
287
|
+
|
|
288
|
+
### VM provider and GPU/VFIO posture
|
|
289
|
+
|
|
290
|
+
The Launch instance modal uses sandbox loadout compatibility metadata instead
|
|
291
|
+
of assuming every VM provider supports every launch mode. Operators can select
|
|
292
|
+
an advertised VM provider and GPU/VFIO posture. Required GPU passthrough sends
|
|
293
|
+
`required_capabilities: ['device.vfio']`, excludes fast-start capabilities, and
|
|
294
|
+
sets `fallback_mode: 'fail'` so Cockpit requests a cold VM launch rather than a
|
|
295
|
+
snapshot/checkpoint path that would be unsafe for assigned devices. Inventory
|
|
296
|
+
then displays assigned VFIO devices and executor-provided incompatibility
|
|
297
|
+
reasons; it does not make VFIO generally available on hosts or loadouts that
|
|
298
|
+
cannot provide it.
|
|
299
|
+
|
|
300
|
+
The Apple Silicon validation path is preview-only. The sandbox `v2026.7.14`
|
|
301
|
+
macOS artifact is an unsigned, unnotarized developer package, and native macOS
|
|
302
|
+
is expected to lack Linux VM, Cloud Hypervisor, VFIO/GPU, vsock, and systemd
|
|
303
|
+
capabilities unless the executor reports otherwise.
|
|
304
|
+
|
|
278
305
|
### Recover stale agents
|
|
279
306
|
|
|
280
307
|
When a container, Docker, or VM runtime is still running but its agent
|
|
@@ -287,18 +314,25 @@ resolve an agent id.
|
|
|
287
314
|
The Bridge handles recovery through:
|
|
288
315
|
|
|
289
316
|
1. executor-owned reconnect endpoints when the sandbox exposes one, then
|
|
290
|
-
2. local Docker fallback
|
|
317
|
+
2. local Docker fallback, only when
|
|
318
|
+
`AIWG_COCKPIT_LOCAL_DOCKER_FALLBACK=1`: `docker exec <container> agent-reconnect`,
|
|
319
|
+
or
|
|
291
320
|
3. VM fallback (#1778): the same reconnect SIGHUP delivered through the libvirt
|
|
292
321
|
qemu-guest-agent channel (`virsh qemu-agent-command <domain> guest-exec
|
|
293
|
-
pkill -HUP -x agent-client`).
|
|
322
|
+
pkill -HUP -x agent-client`). The libvirt fallback is automatic on Linux;
|
|
323
|
+
on non-Linux hosts set `AIWG_COCKPIT_LOCAL_LIBVIRT_FALLBACK=1` for explicit
|
|
324
|
+
local-development use. Sessions survive reconnect on agentic-sandbox
|
|
294
325
|
2026.7.8+ agents; older agents preserve only detached tmux sessions
|
|
295
326
|
(agentic-sandbox#634).
|
|
296
327
|
|
|
297
328
|
The Docker fallback requires sandbox images that include the `agent-reconnect`
|
|
298
329
|
helper (agentic-sandbox v2026.7.5+ images); the VM fallback requires virsh
|
|
299
|
-
access to the domain from the Bridge host. If **Reconnect** reports
|
|
300
|
-
|
|
301
|
-
|
|
330
|
+
access to the domain from the Bridge host. If **Reconnect** reports
|
|
331
|
+
`local_docker_fallback_disabled` or `local_libvirt_fallback_disabled`, either
|
|
332
|
+
let the sandbox handle reconnect through its own endpoint or enable the matching
|
|
333
|
+
local-development fallback intentionally. If no reconnect path is available,
|
|
334
|
+
rebuild or repull the sandbox image, then start the instance again. For host
|
|
335
|
+
targets, prefer starting Cockpit with the host daemon:
|
|
302
336
|
|
|
303
337
|
```bash
|
|
304
338
|
AIWG_COCKPIT_START_HOST_DAEMON=1 npm run cockpit:up
|
|
@@ -333,6 +367,7 @@ Tests run **at stages** — committed harnesses, never `/tmp` rigs (#1635):
|
|
|
333
367
|
| **Dev e2e** (full control-plane chain: health→inventory→create session→attach) | `npm run e2e:cockpit-dev` | **real**, safe-skip when absent | non-blocking |
|
|
334
368
|
| **Daily Linux operator gate** (protected auth + host/container + recovery + upgrade/rollback, #1842) | `npm run uat:cockpit-daily` | **real**, required/fail-closed | operator-scheduled |
|
|
335
369
|
| **Release matrix** (host/docker/vm + provider workload, #1621) | `npm run uat:cockpit-live:matrix` | **real**, all three families | release gate |
|
|
370
|
+
| **Provider-aware VM fast start** (#1843) | `npm --prefix apps/cockpit/web run test -- Inventory.test.tsx` · `node apps/cockpit/bridge/src/smoke.mjs` | **mock** plus live executor for release signoff | required before enabling new VM controls |
|
|
336
371
|
|
|
337
372
|
```bash
|
|
338
373
|
npm --prefix apps/cockpit run check # build web + typecheck + render/a11y tests + smokes + PoCs
|
|
@@ -349,7 +384,10 @@ host/container working-directory expectations, cleanup boundary, and report
|
|
|
349
384
|
schema are documented in
|
|
350
385
|
[Cockpit Daily Linux Operator Gate](../../docs/cockpit/daily-operator-gate.md).
|
|
351
386
|
VM and Apple remain reported preview tiers and do not block the first Linux
|
|
352
|
-
supported result.
|
|
387
|
+
supported result. Apple preview evidence is based on the agentic-sandbox
|
|
388
|
+
developer package, `darwin/arm64` host runtime discovery, and Docker Desktop
|
|
389
|
+
runtime posture; production signing/notarization is tracked separately and is
|
|
390
|
+
not required to pass the Cockpit Linux gate.
|
|
353
391
|
|
|
354
392
|
The React UI is also browser-verified per surface (see `.playwright-mcp/cockpit-*.png`).
|
|
355
393
|
Conformance (`agentic-sandbox-conformance`) was 33 pass / 0 fail / 17 skip; the
|
|
@@ -405,7 +443,8 @@ families in inventory. For each target it verifies inventory normalization,
|
|
|
405
443
|
runtime and transport posture, session backend evidence, session create/list,
|
|
406
444
|
observe attach, and a minimal provider-backed workload through a controller
|
|
407
445
|
session when control is advertised. The selected provider is invoked through the
|
|
408
|
-
attached session (`codex exec -s read-only ...`
|
|
446
|
+
attached session (`codex exec --skip-git-repo-check -s read-only ...`
|
|
447
|
+
for gate-owned non-repository workspaces,
|
|
409
448
|
or `claude --print --permission-mode dontAsk --output-format text ...`) and must
|
|
410
449
|
emit `AIWG_COCKPIT_LIVE_OK` and the expected discovery result (`issue-audit` by
|
|
411
450
|
default). This proves a pre-authenticated agentic framework actually launched in
|
package/bridge/src/server.mjs
CHANGED
|
@@ -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 (
|
|
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),
|
|
@@ -1026,7 +1409,11 @@ function defaultSessionLaunch(instance) {
|
|
|
1026
1409
|
};
|
|
1027
1410
|
}
|
|
1028
1411
|
if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
|
|
1029
|
-
|
|
1412
|
+
// Prefer the executor-reported target-local cwd. Current agentic-sandbox
|
|
1413
|
+
// container and VM contracts report `/home/agent`; retain that value as a
|
|
1414
|
+
// compatibility fallback for older inventory responses. `/root` is not
|
|
1415
|
+
// readable by the mandatory uid 10001 container identity.
|
|
1416
|
+
const home = instance?.launch_context?.cwd ?? '/home/agent';
|
|
1030
1417
|
return {
|
|
1031
1418
|
command: '/bin/bash',
|
|
1032
1419
|
args: ['-lc', `cd ${shellSingleQuote(home)} && exec /bin/bash -l`],
|
|
@@ -1154,7 +1541,8 @@ async function getAgentBackedHostInventory(executorUrl, degradedDetail) {
|
|
|
1154
1541
|
}
|
|
1155
1542
|
|
|
1156
1543
|
/** Normalize the executor's admin inventory into the Bridge's UI shape. */
|
|
1157
|
-
async function getInventory(executorUrl) {
|
|
1544
|
+
async function getInventory(executorUrl, { requireSandboxMtls = false } = {}) {
|
|
1545
|
+
const bootstrapTrust = await getBootstrapTrustPosture(executorUrl, { requireSandboxMtls });
|
|
1158
1546
|
const { target, status, body } = await fetchJsonFirst([
|
|
1159
1547
|
`${executorUrl}/admin/instances`,
|
|
1160
1548
|
`${executorUrl}/api/v2/admin/instances`,
|
|
@@ -1176,6 +1564,7 @@ async function getInventory(executorUrl) {
|
|
|
1176
1564
|
count: 0,
|
|
1177
1565
|
degraded_admin_inventory: detail,
|
|
1178
1566
|
admin_error: body,
|
|
1567
|
+
bootstrap_trust: bootstrapTrust,
|
|
1179
1568
|
instances: [],
|
|
1180
1569
|
};
|
|
1181
1570
|
}
|
|
@@ -1190,6 +1579,8 @@ async function getInventory(executorUrl) {
|
|
|
1190
1579
|
admin_path: new URL(target).pathname,
|
|
1191
1580
|
fetched_at: new Date().toISOString(),
|
|
1192
1581
|
count: normalized.length,
|
|
1582
|
+
bootstrap_trust: bootstrapTrust,
|
|
1583
|
+
degraded_providers: body?.degraded_providers,
|
|
1193
1584
|
instances: normalized,
|
|
1194
1585
|
};
|
|
1195
1586
|
}
|
|
@@ -1240,6 +1631,8 @@ async function getLoadouts(executorUrl) {
|
|
|
1240
1631
|
label: l.label ?? l.display_name ?? l.displayName ?? id,
|
|
1241
1632
|
description: l.description ?? l.summary,
|
|
1242
1633
|
runtimes: l.runtimes ?? l.runtime_kinds ?? l.supported_runtimes,
|
|
1634
|
+
runtime_options: l.runtime_options ?? l.runtimeOptions,
|
|
1635
|
+
compatibility: l.compatibility,
|
|
1243
1636
|
};
|
|
1244
1637
|
}).filter((l) => l.id);
|
|
1245
1638
|
return { source: executorUrl, loadouts_path: new URL(target).pathname, count: loadouts.length, loadouts };
|
|
@@ -1854,6 +2247,7 @@ export function createBridge({
|
|
|
1854
2247
|
allowMockExecutor = ALLOW_MOCK_EXECUTOR,
|
|
1855
2248
|
token,
|
|
1856
2249
|
executorTokenFile = EXECUTOR_TOKEN_FILE,
|
|
2250
|
+
requireSandboxMtls = REQUIRE_SANDBOX_MTLS,
|
|
1857
2251
|
} = {}) {
|
|
1858
2252
|
const upstreamUrl = executorUrl;
|
|
1859
2253
|
const TOKEN = token ?? randomBytes(24).toString('hex');
|
|
@@ -1891,8 +2285,11 @@ export function createBridge({
|
|
|
1891
2285
|
if (url.pathname.startsWith('/api/')) {
|
|
1892
2286
|
try {
|
|
1893
2287
|
await assertRealExecutor(upstreamUrl, allowMockExecutor);
|
|
2288
|
+
if (requireSandboxMtls) {
|
|
2289
|
+
await getBootstrapTrustPosture(upstreamUrl, { requireSandboxMtls: true });
|
|
2290
|
+
}
|
|
1894
2291
|
} catch (err) {
|
|
1895
|
-
return json(res, 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err) });
|
|
2292
|
+
return json(res, Number(err?.upstreamStatus) || 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err), recovery: err?.recovery });
|
|
1896
2293
|
}
|
|
1897
2294
|
}
|
|
1898
2295
|
if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
@@ -1910,8 +2307,13 @@ export function createBridge({
|
|
|
1910
2307
|
req.on('close', () => clearInterval(timer));
|
|
1911
2308
|
return;
|
|
1912
2309
|
}
|
|
1913
|
-
if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
|
|
2310
|
+
if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl, { requireSandboxMtls }));
|
|
1914
2311
|
if (url.pathname === '/api/executor/capabilities') return json(res, 200, await getExecutorCapabilities(upstreamUrl));
|
|
2312
|
+
if (url.pathname === '/api/bootstrap/readiness' && req.method === 'GET') {
|
|
2313
|
+
return json(res, 200, await getBootstrapTrustPosture(upstreamUrl, { requireSandboxMtls }));
|
|
2314
|
+
}
|
|
2315
|
+
if (url.pathname === '/api/mcp/discovery' && req.method === 'GET') return json(res, 200, await getMcpDiscovery(upstreamUrl));
|
|
2316
|
+
if (url.pathname === '/api/mcp' && req.method === 'POST') return proxyMcpRequest(req, res, upstreamUrl, MCP_TOKEN_FILE);
|
|
1915
2317
|
if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
|
|
1916
2318
|
if (url.pathname === '/api/missions') return json(res, 200, await getMissions(upstreamUrl));
|
|
1917
2319
|
if (url.pathname === '/api/events/snapshot') return json(res, 200, await getEventSnapshot(upstreamUrl));
|
|
@@ -2175,6 +2577,29 @@ export function createBridge({
|
|
|
2175
2577
|
}
|
|
2176
2578
|
|
|
2177
2579
|
// --- management surface (UC-012): lifecycle + task cancel ---
|
|
2580
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(snapshot|checkpoint|restore|fork|warm-pool)$/)) && req.method === 'POST') {
|
|
2581
|
+
const parsed = await readJsonBody(req);
|
|
2582
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
2583
|
+
const instanceId = decodeURIComponent(m[1]);
|
|
2584
|
+
const action = m[2];
|
|
2585
|
+
const body = parsed.body || {};
|
|
2586
|
+
const before = await appendAudit('instance.fast_start.requested', {
|
|
2587
|
+
instance_id: instanceId,
|
|
2588
|
+
action,
|
|
2589
|
+
asset_ref: body.asset_ref ?? body.assetRef ?? body.snapshot_id ?? body.snapshotId ?? body.checkpoint_id ?? body.checkpointId ?? body.pool,
|
|
2590
|
+
name: body.name ?? body.child_name ?? body.childName,
|
|
2591
|
+
});
|
|
2592
|
+
const result = await providerFastStartAction(upstreamUrl, instanceId, action, body);
|
|
2593
|
+
await appendAudit('instance.fast_start.accepted', {
|
|
2594
|
+
request_ts: before.ts,
|
|
2595
|
+
instance_id: instanceId,
|
|
2596
|
+
action,
|
|
2597
|
+
status: result.status,
|
|
2598
|
+
operation_id: result.body?.id ?? result.body?.operation?.id,
|
|
2599
|
+
result: result.body,
|
|
2600
|
+
});
|
|
2601
|
+
return json(res, result.status, result.body);
|
|
2602
|
+
}
|
|
2178
2603
|
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop)$/)) && req.method === 'POST') {
|
|
2179
2604
|
const result = await fetchJsonFirst([
|
|
2180
2605
|
`${upstreamUrl}/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
|