@aiwg/cockpit 2026.7.11 → 2026.7.12
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 +80 -12
- package/bridge/src/server.mjs +301 -21
- package/bridge/src/smoke.mjs +3 -3
- package/package.json +2 -2
- package/runtime-docs/README.md +3 -2
- package/shell-core/keychain.mjs +35 -5
- package/vscode/extension.js +13 -26
- package/vscode/smoke.mjs +47 -0
- package/web/src/App.test.tsx +119 -1
- package/web/src/App.tsx +58 -27
- package/web/src/components/Actions.tsx +1 -1
- package/web/src/components/Inventory.tsx +61 -14
- package/web/src/components/Library.tsx +1 -1
- package/web/src/components/Sessions.test.tsx +248 -10
- package/web/src/components/Sessions.tsx +236 -65
- package/web/src/components/StartSessionModal.test.tsx +1 -1
- package/web/src/components/StartSessionModal.tsx +7 -1
- package/web/src/components/Welcome.tsx +11 -10
- package/web/src/sessionMonitor.test.tsx +85 -0
- package/web/src/sessionMonitor.ts +72 -0
- package/web/src/sessionRegistry.test.ts +112 -0
- package/web/src/sessionRegistry.ts +192 -0
- package/web/src/styles.css +20 -2
- package/web/src/types.ts +23 -1
- package/web/src/useSession.test.tsx +207 -7
- package/web/src/useSession.ts +353 -193
- package/web/src/util.ts +8 -1
package/README.md
CHANGED
|
@@ -58,7 +58,8 @@ AIWG_COCKPIT_EXECUTOR_URL=http://127.0.0.1:8122 aiwg cockpit
|
|
|
58
58
|
- **Home** — connected-state overview, first-run flow, and a session-first entry point.
|
|
59
59
|
- **Inventory** — host, container, Docker, and VM runtime targets with lifecycle controls.
|
|
60
60
|
- **Running** — active work across stacks, spend posture, and task stop controls.
|
|
61
|
-
- **
|
|
61
|
+
- **Missions** — read-only Mission Control projection: sessions, per-mission status, audit tail.
|
|
62
|
+
- **Sessions** — observe-first terminal attach, explicit drive/control, replay posture, and stale-agent recovery.
|
|
62
63
|
- **Approvals** — unified human-in-the-loop decision inbox.
|
|
63
64
|
- **Explore** — live index status/query/rebuild plus read-only AIWG capability catalog.
|
|
64
65
|
- **Library** — user-owned assets cloned/imported under `~/.aiwg/cockpit/library`.
|
|
@@ -138,7 +139,8 @@ operator / CLI: aiwg cockpit
|
|
|
138
139
|
| **Home** | Guided first-run: what-is-this, live status, the **Start a session** primary verb, first-run tour. |
|
|
139
140
|
| **Inventory** | Instances + lifecycle (Start/Stop/Destroy). |
|
|
140
141
|
| **Running** | Running work across stacks + cross-stack spend + per-task Stop. |
|
|
141
|
-
| **
|
|
142
|
+
| **Missions** | Read-only Mission Control projection — durable `aiwg mc` sessions merged with the live executor task session. |
|
|
143
|
+
| **Sessions** | Live pty terminal — observe/drive, keyframe, non-destructive replay, stale-agent recovery; inline **+ capability picker**. |
|
|
142
144
|
| **Approvals** | Unified HITL inbox (`hitl-prompt/v1`); decisions = operator authorization. |
|
|
143
145
|
| **Explore** | Live artifact-index status/query/rebuild plus read-only AIWG catalog search. |
|
|
144
146
|
| **Library** | Your own assets — clone from the catalog / import / remove. AIWG files never overwritten. |
|
|
@@ -177,9 +179,10 @@ Set `AIWG_COCKPIT_KEYCHAIN_STRICT=1` when shells must refuse plaintext runtime
|
|
|
177
179
|
tokens. In strict mode the Bridge exits if it cannot persist the per-launch token
|
|
178
180
|
to the OS keychain, and shell-core refuses runtime files that only contain a
|
|
179
181
|
plaintext token. Operator intent is also recorded in a local redacted audit log
|
|
180
|
-
under `~/.aiwg/cockpit/audit/events.jsonl` for lifecycle, session,
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
under `~/.aiwg/cockpit/audit/events.jsonl` for lifecycle, session, and
|
|
183
|
+
approval-response decisions (the web UI additionally records action injections
|
|
184
|
+
as operator intents); bearer material and provider credentials are redacted
|
|
185
|
+
before write.
|
|
183
186
|
|
|
184
187
|
## Run (dev/test, against a real agentic-sandbox executor)
|
|
185
188
|
|
|
@@ -196,6 +199,17 @@ binary before serving Cockpit. Override `AIWG_COCKPIT_EXECUTOR_URL` (default
|
|
|
196
199
|
`http://127.0.0.1:8122`) or `PORT` (default `8140`). Set
|
|
197
200
|
`AIWG_COCKPIT_EXECUTOR_COMMAND` to pin the autostart command, or
|
|
198
201
|
`AIWG_COCKPIT_AUTOSTART_EXECUTOR=0` to require an already-running executor.
|
|
202
|
+
|
|
203
|
+
**Bring up both halves at once (#1634).** `npm run cockpit:up` (→
|
|
204
|
+
`apps/cockpit/scripts/cockpit-up.sh`) guarantees a real, current executor is
|
|
205
|
+
listening *before* the Bridge starts: it launches the agentic-sandbox executor
|
|
206
|
+
via its own `management/dev.sh` when one isn't already healthy on
|
|
207
|
+
`AIWG_COCKPIT_EXECUTOR_URL` (default `http://127.0.0.1:8122`), then delegates to
|
|
208
|
+
`cockpit-dev.sh` for the Bridge + UI. Set `AIWG_COCKPIT_ENSURE_EXECUTOR=0` to
|
|
209
|
+
skip the executor-ensure step when a sandbox is already running, or
|
|
210
|
+
`AIWG_COCKPIT_START_HOST_DAEMON=1` to also start the optional host-runtime
|
|
211
|
+
daemon. `cockpit-dev.sh` stays the Cockpit-only launcher.
|
|
212
|
+
|
|
199
213
|
Equivalent manual steps:
|
|
200
214
|
|
|
201
215
|
```bash
|
|
@@ -217,10 +231,10 @@ agentic-sandbox executor. The bundled mock is reserved for automated tests and
|
|
|
217
231
|
PoCs; if a mock-like executor is detected, the Bridge refuses it unless
|
|
218
232
|
`AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR=1` is set by an automated harness.
|
|
219
233
|
|
|
220
|
-
The Bridge
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`/
|
|
234
|
+
The Bridge probes legacy (`/admin/instances`) and v2 (`/api/v2/admin/instances`)
|
|
235
|
+
admin surfaces as fallback candidates for inventory and lifecycle; running work
|
|
236
|
+
and approvals are derived from per-instance A2A task lists rather than any
|
|
237
|
+
admin `/running` route. Field normalization covers snake_case and camelCase
|
|
224
238
|
payloads so live sandboxes can evolve without breaking the operator UI; unknown
|
|
225
239
|
fields degrade to opaque posture rather than failing the screen.
|
|
226
240
|
|
|
@@ -230,6 +244,39 @@ QEMU/VM launches. Cockpit does not replace attached sessions when provisioning;
|
|
|
230
244
|
it refreshes inventory and leaves concurrency and resource admission to
|
|
231
245
|
agentic-sandbox.
|
|
232
246
|
|
|
247
|
+
### Recover stale agents
|
|
248
|
+
|
|
249
|
+
When a container, Docker, or VM runtime is still running but its agent
|
|
250
|
+
registration has disappeared, Cockpit keeps the row visible instead of hiding
|
|
251
|
+
it. Inventory and Sessions show `agent unreachable` and expose **Reconnect**
|
|
252
|
+
for stale container/Docker **and vm/qemu/kvm** rows (#1778). Use this when an
|
|
253
|
+
instance is alive but session attach, running projections, or actions cannot
|
|
254
|
+
resolve an agent id.
|
|
255
|
+
|
|
256
|
+
The Bridge handles recovery through:
|
|
257
|
+
|
|
258
|
+
1. executor-owned reconnect endpoints when the sandbox exposes one, then
|
|
259
|
+
2. local Docker fallback: `docker exec <container> agent-reconnect`, or
|
|
260
|
+
3. VM fallback (#1778): the same reconnect SIGHUP delivered through the libvirt
|
|
261
|
+
qemu-guest-agent channel (`virsh qemu-agent-command <domain> guest-exec
|
|
262
|
+
pkill -HUP -x agent-client`). Sessions survive reconnect on agentic-sandbox
|
|
263
|
+
2026.7.8+ agents; older agents preserve only detached tmux sessions
|
|
264
|
+
(agentic-sandbox#634).
|
|
265
|
+
|
|
266
|
+
The Docker fallback requires sandbox images that include the `agent-reconnect`
|
|
267
|
+
helper (agentic-sandbox v2026.7.5+ images); the VM fallback requires virsh
|
|
268
|
+
access to the domain from the Bridge host. If **Reconnect** reports that no
|
|
269
|
+
reconnect path is available, rebuild or repull the sandbox image, then start the
|
|
270
|
+
instance again. For host targets, prefer starting Cockpit with the host daemon:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
AIWG_COCKPIT_START_HOST_DAEMON=1 npm run cockpit:up
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
After a successful reconnect, refresh Inventory, then attach from Sessions. A
|
|
277
|
+
Reconnect action never creates a replacement instance and never destroys the
|
|
278
|
+
running container; it only attempts to restore the missing agent registration.
|
|
279
|
+
|
|
233
280
|
`aiwg cockpit` (the operator command) will wrap this; the Bridge serves the built
|
|
234
281
|
React app token-injected, falling back to a legacy page when no build is present.
|
|
235
282
|
|
|
@@ -402,6 +449,25 @@ Known state as of the 2026-06-19 host live run:
|
|
|
402
449
|
`v2026.6.34`: VM matrix PASS — provision → vsock enroll → boot-ready → provider
|
|
403
450
|
workload → clean destroy. Evidence:
|
|
404
451
|
`.aiwg/testing/cockpit-vm-vsock-2026-06-27.md/.json`.
|
|
452
|
+
- Instance **transport posture + host-daemon** now surface from the sandbox side
|
|
453
|
+
(`dd97529 fix(admin-v2): expose instance transport posture`, plus `#611`
|
|
454
|
+
host-runtime session listing). Re-validated Cockpit-side against `v2026.7.4`
|
|
455
|
+
(2026-07-09): Inventory renders real posture per instance — Host + Container
|
|
456
|
+
`Secure transport · mtls` (Host daemon `available`), enrolled VM `Local
|
|
457
|
+
transport · vsock`, a mid-bootstrap VM showing transport `Unknown` with the
|
|
458
|
+
informative posture `bootstrap-pending` (vs the old dead `unknown`; every
|
|
459
|
+
enrolled instance now renders real posture). Session-list returns
|
|
460
|
+
cleanly (no 502 — the `#140`/`#611` endpoints are live). Runtime coverage
|
|
461
|
+
banner `host ✓ · docker ✓ · vm ✓`. Evidence:
|
|
462
|
+
`.aiwg/testing/cockpit-7.4-transport-verify-2026-07-09.md` +
|
|
463
|
+
`.aiwg/working/cockpit-7.4-inventory-2026-07-09.png`.
|
|
464
|
+
- Stale Docker/container agent recovery is wired through the Bridge and UI
|
|
465
|
+
(2026-07-11): stale running instances remain visible as `agent unreachable`,
|
|
466
|
+
Inventory and Sessions expose **Reconnect**, and the Bridge tries executor
|
|
467
|
+
reconnect before Docker `agent-reconnect`. Host-daemon scoped live UAT passed
|
|
468
|
+
against the real executor with `AIWG_COCKPIT_LIVE_PROVISION=1`,
|
|
469
|
+
`AIWG_COCKPIT_LIVE_MATRIX_TARGETS=host`, and Codex provider workload evidence
|
|
470
|
+
at `test-results/cockpit-live-host-daemon-2026-07-11.md/.json`.
|
|
405
471
|
- Remaining upstream follow-ups are Claude auth-state propagation
|
|
406
472
|
(roctinam/agentic-sandbox#499) and agent-scoped PTY sessions not appearing in
|
|
407
473
|
the formal/global session registry (roctinam/agentic-sandbox#500).
|
|
@@ -418,9 +484,11 @@ agentic-sandbox executor through `AIWG_COCKPIT_EXECUTOR_URL`. The host target
|
|
|
418
484
|
(agentic-sandbox#460) and direct/managed multiplexer sessions
|
|
419
485
|
(agentic-sandbox#461) have landed upstream; the Bridge seam is now the
|
|
420
486
|
AIWG-side integration point for #1589. Runtime-tier provisioning and
|
|
421
|
-
host-daemon
|
|
422
|
-
|
|
423
|
-
|
|
487
|
+
host-daemon surfacing (roctinam/aiwg#1615) and transport-trust visibility (#1618)
|
|
488
|
+
**landed and are verified against `v2026.7.4`** — transport posture and
|
|
489
|
+
host-daemon now render per instance (a host-daemon *detail-status* payload
|
|
490
|
+
remains a residual under #1615). Direct/managed PTY negotiation (#1616) and the
|
|
491
|
+
live real-sandbox gate (#1617) continue. Secure transport details map back to agentic-sandbox#409/#410/#412; local
|
|
424
492
|
Browser/Tauri/VS Code-to-Bridge auth remains roctinam/aiwg#1595.
|
|
425
493
|
|
|
426
494
|
### Operator-wall review modes (#1622)
|
package/bridge/src/server.mjs
CHANGED
|
@@ -357,7 +357,23 @@ async function proxy(res, method, target) {
|
|
|
357
357
|
return json(res, r.status, body);
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
|
|
360
|
+
const SAFE_FALLBACK_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
361
|
+
|
|
362
|
+
function isAbortError(err) {
|
|
363
|
+
return err?.name === 'AbortError' || /aborted|abort/i.test(String(err?.message ?? err));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isConnectionRefusedError(err) {
|
|
367
|
+
const text = [
|
|
368
|
+
err?.code,
|
|
369
|
+
err?.cause?.code,
|
|
370
|
+
err?.message,
|
|
371
|
+
err?.cause?.message,
|
|
372
|
+
].filter(Boolean).join(' ');
|
|
373
|
+
return /ECONNREFUSED|connection refused/i.test(text);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption, timeoutMs = 0 } = {}) {
|
|
361
377
|
const failures = [];
|
|
362
378
|
for (const candidate of candidates) {
|
|
363
379
|
const target = typeof candidate === 'string' ? candidate : candidate.target;
|
|
@@ -370,7 +386,13 @@ async function fetchJsonFirst(candidates, { method = 'GET', headers, body: reque
|
|
|
370
386
|
try {
|
|
371
387
|
r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
|
|
372
388
|
} catch (err) {
|
|
373
|
-
|
|
389
|
+
const failure = isAbortError(err) && timeoutMs > 0
|
|
390
|
+
? `${target} -> timeout after ${timeoutMs}ms`
|
|
391
|
+
: `${target} -> ${String(err?.message ?? err)}`;
|
|
392
|
+
failures.push(failure);
|
|
393
|
+
const safeToTryNext = SAFE_FALLBACK_METHODS.has(String(requestMethod).toUpperCase())
|
|
394
|
+
|| isConnectionRefusedError(err);
|
|
395
|
+
if (!safeToTryNext) throw new Error(failures.join('; '));
|
|
374
396
|
continue;
|
|
375
397
|
} finally {
|
|
376
398
|
if (timeout) clearTimeout(timeout);
|
|
@@ -580,6 +602,138 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
580
602
|
};
|
|
581
603
|
}
|
|
582
604
|
|
|
605
|
+
// #1778: VM-runtime counterpart of `docker exec <ctr> agent-reconnect`. Sandbox
|
|
606
|
+
// VM images bake qemu-guest-agent ("essential for virsh exec") and agent-rs
|
|
607
|
+
// handles SIGHUP as reconnect-in-place on every runtime, so delivering
|
|
608
|
+
// `pkill -HUP -x agent-client` through the libvirt guest-agent channel makes
|
|
609
|
+
// the agent re-register without touching the VM. Session survival is
|
|
610
|
+
// version-conditional: agentic-sandbox 2026.7.8+ agents preserve all sessions
|
|
611
|
+
// across reconnect; older agents preserve only detached-tmux sessions
|
|
612
|
+
// (agentic-sandbox#634).
|
|
613
|
+
async function signalVmAgentReconnect(domain) {
|
|
614
|
+
const execRaw = await spawnCollect('virsh', ['qemu-agent-command', domain, JSON.stringify({
|
|
615
|
+
execute: 'guest-exec',
|
|
616
|
+
arguments: { path: '/bin/sh', arg: ['-c', 'pkill -HUP -x agent-client'], 'capture-output': true },
|
|
617
|
+
})]);
|
|
618
|
+
const pid = JSON.parse(String(execRaw))?.return?.pid;
|
|
619
|
+
if (!Number.isInteger(pid)) throw new Error(`guest-exec returned no pid: ${String(execRaw).trim()}`);
|
|
620
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
621
|
+
const statusRaw = await spawnCollect('virsh', ['qemu-agent-command', domain, JSON.stringify({
|
|
622
|
+
execute: 'guest-exec-status',
|
|
623
|
+
arguments: { pid },
|
|
624
|
+
})]);
|
|
625
|
+
const status = JSON.parse(String(statusRaw))?.return;
|
|
626
|
+
if (status?.exited) return status.exitcode ?? 0;
|
|
627
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
628
|
+
}
|
|
629
|
+
// The signal command was handed to the guest; slow exec-status reporting is
|
|
630
|
+
// not a delivery failure.
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const VM_RUNTIME_KINDS = ['vm', 'qemu', 'kvm'];
|
|
635
|
+
|
|
636
|
+
async function reconnectInstance(upstreamUrl, instanceId) {
|
|
637
|
+
const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
|
|
638
|
+
const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
|
|
639
|
+
const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
|
|
640
|
+
const dockerName = inst?.launch_context?.name;
|
|
641
|
+
const candidates = [
|
|
642
|
+
{ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
|
|
643
|
+
{ target: `${upstreamUrl}/admin/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
|
|
644
|
+
{ target: `${upstreamUrl}/api/v1/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
|
|
645
|
+
];
|
|
646
|
+
try {
|
|
647
|
+
const result = await fetchJsonFirst(candidates, { timeoutMs: 5_000 });
|
|
648
|
+
if (result.status < 400) return result;
|
|
649
|
+
} catch {
|
|
650
|
+
// agentic-sandbox v2026.7.6 still exposes the container reconnect as an
|
|
651
|
+
// in-image helper, not an HTTP endpoint. Fall through to the local-dev path.
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (['docker', 'container'].includes(runtime) && dockerName) {
|
|
655
|
+
try {
|
|
656
|
+
const output = await spawnCollect('docker', ['exec', dockerName, 'agent-reconnect']);
|
|
657
|
+
return {
|
|
658
|
+
target: `docker exec ${dockerName} agent-reconnect`,
|
|
659
|
+
status: 202,
|
|
660
|
+
body: {
|
|
661
|
+
id: instanceId,
|
|
662
|
+
runtime,
|
|
663
|
+
docker_name: dockerName,
|
|
664
|
+
state: 'reconnecting',
|
|
665
|
+
message: `Reconnect requested for ${dockerName}; inventory will refresh as the agent re-registers.`,
|
|
666
|
+
output: String(output).trim(),
|
|
667
|
+
fallback: 'docker-agent-reconnect',
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
} catch (err) {
|
|
671
|
+
return {
|
|
672
|
+
target: `docker exec ${dockerName} agent-reconnect`,
|
|
673
|
+
status: 502,
|
|
674
|
+
body: {
|
|
675
|
+
error: 'reconnect_failed',
|
|
676
|
+
message: `Could not run agent-reconnect in ${dockerName}. Repull/rebuild the agent image if it predates agentic-sandbox v2026.7.5.`,
|
|
677
|
+
detail: String(err?.message ?? err),
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (VM_RUNTIME_KINDS.includes(runtime)) {
|
|
684
|
+
// For VM instances the agent_id doubles as the libvirt domain name
|
|
685
|
+
// (agentic-sandbox provision-vm.sh registers agent_id = $vm_name).
|
|
686
|
+
const domain = dockerName ?? inst?.name ?? String(instanceId);
|
|
687
|
+
const target = `virsh qemu-agent-command ${domain} guest-exec pkill -HUP -x agent-client`;
|
|
688
|
+
try {
|
|
689
|
+
const exitcode = await signalVmAgentReconnect(domain);
|
|
690
|
+
if (exitcode === 0) {
|
|
691
|
+
return {
|
|
692
|
+
target,
|
|
693
|
+
status: 202,
|
|
694
|
+
body: {
|
|
695
|
+
id: instanceId,
|
|
696
|
+
runtime,
|
|
697
|
+
vm_domain: domain,
|
|
698
|
+
state: 'reconnecting',
|
|
699
|
+
message: `Reconnect requested for VM ${domain}; inventory will refresh as the agent re-registers. Sessions survive reconnect on agentic-sandbox 2026.7.8+ agents; older agents preserve only detached tmux sessions (agentic-sandbox#634).`,
|
|
700
|
+
fallback: 'virsh-guest-agent-sighup',
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
target,
|
|
706
|
+
status: 502,
|
|
707
|
+
body: {
|
|
708
|
+
error: 'reconnect_failed',
|
|
709
|
+
message: `No running agent-client process found inside VM ${domain}. Restart the agent service in the guest (systemctl restart agent-client) or reprovision the VM.`,
|
|
710
|
+
exitcode,
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
} catch (err) {
|
|
714
|
+
return {
|
|
715
|
+
target,
|
|
716
|
+
status: 502,
|
|
717
|
+
body: {
|
|
718
|
+
error: 'reconnect_failed',
|
|
719
|
+
message: `Could not signal agent-client in VM ${domain} via qemu-guest-agent. The bridge host needs virsh access to the libvirt domain and the guest-agent channel must be up (agentic-sandbox#633).`,
|
|
720
|
+
detail: String(err?.message ?? err),
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`,
|
|
728
|
+
status: 409,
|
|
729
|
+
body: {
|
|
730
|
+
error: 'reconnect_unavailable',
|
|
731
|
+
message: 'Reconnect is available for Docker/container instances (docker exec) and VM instances (qemu-guest-agent). For host-runtime agents, signal the agent directly: pkill -HUP -x agent-client.',
|
|
732
|
+
runtime: runtime || 'unknown',
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
583
737
|
function asArrayFromEnvelope(body, keys) {
|
|
584
738
|
if (Array.isArray(body)) return body;
|
|
585
739
|
if (!body || typeof body !== 'object') return [];
|
|
@@ -594,10 +748,22 @@ function asArrayFromEnvelope(body, keys) {
|
|
|
594
748
|
return [];
|
|
595
749
|
}
|
|
596
750
|
|
|
751
|
+
const AGENT_ID_CACHE_TTL_MS = Number(process.env.AIWG_COCKPIT_AGENT_CACHE_TTL_MS ?? 5_000);
|
|
752
|
+
const agentListCache = new Map();
|
|
753
|
+
|
|
754
|
+
async function getAgentList(executorUrl) {
|
|
755
|
+
const cached = agentListCache.get(executorUrl);
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
if (cached && cached.expiresAt > now) return cached.agents;
|
|
758
|
+
const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
|
|
759
|
+
const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
760
|
+
agentListCache.set(executorUrl, { agents, expiresAt: now + AGENT_ID_CACHE_TTL_MS });
|
|
761
|
+
return agents;
|
|
762
|
+
}
|
|
763
|
+
|
|
597
764
|
async function resolveSessionAgentId(executorUrl, instanceId) {
|
|
598
765
|
try {
|
|
599
|
-
const
|
|
600
|
-
const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
766
|
+
const agents = await getAgentList(executorUrl);
|
|
601
767
|
const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(instanceId));
|
|
602
768
|
return agent?.id ?? agent?.agent_id ?? agent?.agentId ?? instanceId;
|
|
603
769
|
} catch {
|
|
@@ -769,17 +935,23 @@ function normalizeInstance(executorUrl, i) {
|
|
|
769
935
|
function defaultSessionLaunch(instance) {
|
|
770
936
|
const runtime = String(instance?.runtime_posture?.kind ?? instance?.runtime ?? '').toLowerCase();
|
|
771
937
|
if (runtime === 'host') {
|
|
938
|
+
// Honor the executor-reported cwd — it is valid on the target host, including
|
|
939
|
+
// remote worker hosts whose filesystem does not mirror the Bridge machine.
|
|
940
|
+
// Fall back to the operator home only when the executor reports no cwd;
|
|
941
|
+
// hardcoding homedir() would resolve the Bridge's local home and break
|
|
942
|
+
// sessions on any host runtime that is not the Bridge machine itself.
|
|
772
943
|
return {
|
|
773
944
|
command: 'bash',
|
|
774
945
|
args: ['-l'],
|
|
775
|
-
working_dir: instance?.launch_context?.cwd,
|
|
946
|
+
working_dir: instance?.launch_context?.cwd ?? homedir(),
|
|
776
947
|
};
|
|
777
948
|
}
|
|
778
949
|
if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
|
|
950
|
+
const home = runtime === 'container' || runtime === 'docker' ? '/root' : '/home/agent';
|
|
779
951
|
return {
|
|
780
952
|
command: '/bin/bash',
|
|
781
|
-
args: ['-lc',
|
|
782
|
-
working_dir:
|
|
953
|
+
args: ['-lc', `cd ${shellSingleQuote(home)} && exec /bin/bash -l`],
|
|
954
|
+
working_dir: home,
|
|
783
955
|
};
|
|
784
956
|
}
|
|
785
957
|
return {
|
|
@@ -788,6 +960,25 @@ function defaultSessionLaunch(instance) {
|
|
|
788
960
|
};
|
|
789
961
|
}
|
|
790
962
|
|
|
963
|
+
// Deterministic prefix + per-request nonce. The name is IDENTICAL across this
|
|
964
|
+
// request's fallback candidates (so a timed-out-but-created session is
|
|
965
|
+
// recoverable by name, and the executor's 409-by-name guard dedupes candidate
|
|
966
|
+
// retries) but UNIQUE across requests — operators can hold multiple concurrent
|
|
967
|
+
// sessions per (instance, mode, backend). A fully canonical name here silently
|
|
968
|
+
// reused the first session on every subsequent "New session" click.
|
|
969
|
+
function sessionNameFor(instanceId, { mode = 'managed', backend = 'tmux' } = {}) {
|
|
970
|
+
const slug = (value) => String(value || 'default')
|
|
971
|
+
.toLowerCase()
|
|
972
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
973
|
+
.replace(/^-+|-+$/g, '')
|
|
974
|
+
.slice(0, 36) || 'default';
|
|
975
|
+
return `cockpit-${slug(instanceId)}-${slug(mode)}-${slug(backend)}-${randomBytes(3).toString('hex')}`;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function shellSingleQuote(value) {
|
|
979
|
+
return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
|
|
980
|
+
}
|
|
981
|
+
|
|
791
982
|
function runtimeExtensionFromCard(card) {
|
|
792
983
|
const extensions = card?.capabilities?.extensions;
|
|
793
984
|
if (!Array.isArray(extensions)) return null;
|
|
@@ -817,8 +1008,7 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
|
|
|
817
1008
|
|
|
818
1009
|
async function getRegisteredAgents(executorUrl) {
|
|
819
1010
|
try {
|
|
820
|
-
|
|
821
|
-
return asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
1011
|
+
return await getAgentList(executorUrl);
|
|
822
1012
|
} catch {
|
|
823
1013
|
return [];
|
|
824
1014
|
}
|
|
@@ -1220,8 +1410,8 @@ async function getSessionEventRows(executorUrl, instances) {
|
|
|
1220
1410
|
agent_id: session.agent_id,
|
|
1221
1411
|
state: session.state ?? session.status ?? session.session_state ?? 'available',
|
|
1222
1412
|
role_policy: session.role_policy,
|
|
1223
|
-
|
|
1224
|
-
|
|
1413
|
+
session_backend: session.session_backend,
|
|
1414
|
+
session_class: session.session_class,
|
|
1225
1415
|
});
|
|
1226
1416
|
}
|
|
1227
1417
|
}));
|
|
@@ -1248,7 +1438,7 @@ async function getEventSnapshot(executorUrl) {
|
|
|
1248
1438
|
events.push({ id: `approval:${approval.id}`, type: 'hitl.approval', source: 'a2a', subject: approval.task_id ?? approval.id, state: approval.status, severity: approval.risk, ts, ref: { instance_id: approval.instance_id, approval_id: approval.id } });
|
|
1249
1439
|
}
|
|
1250
1440
|
for (const session of sessions ?? []) {
|
|
1251
|
-
events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id,
|
|
1441
|
+
events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id, session_backend: session.session_backend, session_class: session.session_class, role_policy: session.role_policy } });
|
|
1252
1442
|
}
|
|
1253
1443
|
for (const mission of missions.missions ?? []) {
|
|
1254
1444
|
events.push({ id: `mission:${mission.id}`, type: 'mission.lifecycle', source: mission.source ?? 'aiwg-mc', subject: mission.id, state: mission.status, ts, ref: { session_id: mission.session_id, mission_id: mission.id, ralph_loop_id: mission.ralph_loop_id } });
|
|
@@ -1324,6 +1514,50 @@ async function getSessions(executorUrl, instanceId) {
|
|
|
1324
1514
|
return normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId });
|
|
1325
1515
|
}
|
|
1326
1516
|
|
|
1517
|
+
function normalizeScreenSnapshot(body, { instanceId, sessionId, source }) {
|
|
1518
|
+
const text = String(body?.text ?? body?.snapshot ?? body?.screen ?? body?.content ?? '');
|
|
1519
|
+
const rawLines = Array.isArray(body?.lines) ? body.lines : text.replace(/\r/g, '\n').split('\n');
|
|
1520
|
+
return {
|
|
1521
|
+
instance_id: instanceId,
|
|
1522
|
+
session_id: sessionId,
|
|
1523
|
+
text,
|
|
1524
|
+
lines: rawLines.map((line) => String(line)).filter(Boolean).slice(-80),
|
|
1525
|
+
seq: body?.seq ?? body?.sequence ?? body?.anchor_sequence ?? body?.anchorSequence ?? null,
|
|
1526
|
+
fetched_at: new Date().toISOString(),
|
|
1527
|
+
source,
|
|
1528
|
+
snapshot_format: body?.snapshot_format ?? body?.snapshotFormat ?? body?.format ?? 'text/plain',
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async function getSessionScreen(executorUrl, instanceId, sessionId) {
|
|
1533
|
+
const sessionAgentId = await resolveSessionAgentId(executorUrl, instanceId);
|
|
1534
|
+
const agentIds = unique([sessionAgentId, instanceId]);
|
|
1535
|
+
const paths = agentIds.flatMap((agentId) => {
|
|
1536
|
+
const encodedAgent = encodeURIComponent(agentId);
|
|
1537
|
+
const encodedSession = encodeURIComponent(sessionId);
|
|
1538
|
+
return [
|
|
1539
|
+
`${executorUrl}/api/v1/agents/${encodedAgent}/sessions/${encodedSession}/screen`,
|
|
1540
|
+
`${executorUrl}/api/v1/agents/${encodedAgent}/sessions/${encodedSession}/screen-state`,
|
|
1541
|
+
`${executorUrl}/agents/${encodedAgent}/sessions/${encodedSession}/screen`,
|
|
1542
|
+
`${executorUrl}/agents/${encodedAgent}/sessions/${encodedSession}/screen-state`,
|
|
1543
|
+
];
|
|
1544
|
+
});
|
|
1545
|
+
try {
|
|
1546
|
+
const { body, target, status } = await fetchJsonFirst(paths);
|
|
1547
|
+
return { status, body: normalizeScreenSnapshot(body, { instanceId, sessionId, source: target }) };
|
|
1548
|
+
} catch (e) {
|
|
1549
|
+
return {
|
|
1550
|
+
status: 404,
|
|
1551
|
+
body: {
|
|
1552
|
+
error: 'session_screen_unavailable',
|
|
1553
|
+
instance_id: instanceId,
|
|
1554
|
+
session_id: sessionId,
|
|
1555
|
+
detail: String(e?.message ?? e),
|
|
1556
|
+
},
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1327
1561
|
export function normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId = instanceId }) {
|
|
1328
1562
|
const wsBase = executorUrl.replace(/^http/i, 'ws');
|
|
1329
1563
|
const normalizeAttachUrl = (s, sessionId) => {
|
|
@@ -1454,6 +1688,20 @@ async function endSession(executorUrl, instanceId, sessionId) {
|
|
|
1454
1688
|
};
|
|
1455
1689
|
}
|
|
1456
1690
|
|
|
1691
|
+
async function findReusableSession(executorUrl, instanceId, sessionName) {
|
|
1692
|
+
const sessions = (await getSessions(executorUrl, instanceId)).sessions;
|
|
1693
|
+
return sessions.find((s) => String(s.session_name ?? s.sessionName ?? '') === String(sessionName));
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function sessionResponseFromRow(row) {
|
|
1697
|
+
return {
|
|
1698
|
+
...row,
|
|
1699
|
+
id: row.id ?? row.session_id ?? row.sessionId,
|
|
1700
|
+
attach_url: row.attach_url ?? row.attachUrl,
|
|
1701
|
+
reused: true,
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1457
1705
|
export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = ALLOW_MOCK_EXECUTOR, token } = {}) {
|
|
1458
1706
|
const upstreamUrl = executorUrl;
|
|
1459
1707
|
const TOKEN = token ?? randomBytes(24).toString('hex');
|
|
@@ -1576,6 +1824,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1576
1824
|
const { status, body } = await endSession(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
|
|
1577
1825
|
return json(res, status, body);
|
|
1578
1826
|
}
|
|
1827
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/screen$/)) && req.method === 'GET') {
|
|
1828
|
+
const { status, body } = await getSessionScreen(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
|
|
1829
|
+
return json(res, status, body);
|
|
1830
|
+
}
|
|
1579
1831
|
// registry-bound, data-driven core — live, no app restart (#1592)
|
|
1580
1832
|
if (url.pathname === '/api/capabilities') {
|
|
1581
1833
|
const q = (url.searchParams.get('q') || '').trim();
|
|
@@ -1664,6 +1916,7 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1664
1916
|
if (backend) qs.set('backend', backend);
|
|
1665
1917
|
if (loadout) qs.set('loadout', loadout);
|
|
1666
1918
|
const sessionAgentId = await resolveSessionAgentId(upstreamUrl, id);
|
|
1919
|
+
const sessionName = sessionNameFor(id, { mode: mode || 'managed', backend: backend || 'tmux' });
|
|
1667
1920
|
let sessionLaunch = defaultSessionLaunch();
|
|
1668
1921
|
try {
|
|
1669
1922
|
const inventory = await getInventory(upstreamUrl);
|
|
@@ -1672,28 +1925,41 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1672
1925
|
// Session creation can still proceed without an explicit cwd; the
|
|
1673
1926
|
// executor/agent will fall back to its own process cwd.
|
|
1674
1927
|
}
|
|
1928
|
+
// No pre-create reuse lookup: every create request gets its own uniquely
|
|
1929
|
+
// named session (multi-session per instance is supported). Recovery by
|
|
1930
|
+
// name below still catches a create that timed out after succeeding.
|
|
1931
|
+
const sessionBody = JSON.stringify({
|
|
1932
|
+
session_name: sessionName,
|
|
1933
|
+
session_backend: backend || 'tmux',
|
|
1934
|
+
session_class: mode || 'managed',
|
|
1935
|
+
command: sessionLaunch.command,
|
|
1936
|
+
args: sessionLaunch.args,
|
|
1937
|
+
...(sessionLaunch.working_dir ? { working_dir: sessionLaunch.working_dir } : {}),
|
|
1938
|
+
});
|
|
1675
1939
|
const candidates = unique([sessionAgentId, id]).flatMap((agentId) => [
|
|
1676
1940
|
{
|
|
1677
1941
|
target: `${upstreamUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
1678
1942
|
method: 'POST',
|
|
1679
1943
|
headers: { 'content-type': 'application/json' },
|
|
1680
|
-
body:
|
|
1681
|
-
session_backend: backend || 'tmux',
|
|
1682
|
-
session_class: mode || 'managed',
|
|
1683
|
-
command: sessionLaunch.command,
|
|
1684
|
-
args: sessionLaunch.args,
|
|
1685
|
-
...(sessionLaunch.working_dir ? { working_dir: sessionLaunch.working_dir } : {}),
|
|
1686
|
-
}),
|
|
1944
|
+
body: sessionBody,
|
|
1687
1945
|
},
|
|
1688
1946
|
{
|
|
1689
1947
|
target: `${upstreamUrl}/agents/${encodeURIComponent(agentId)}/sessions?${qs.toString()}`,
|
|
1690
1948
|
method: 'POST',
|
|
1949
|
+
headers: { 'content-type': 'application/json' },
|
|
1950
|
+
body: sessionBody,
|
|
1691
1951
|
},
|
|
1692
1952
|
]);
|
|
1693
1953
|
let sessionCreate;
|
|
1694
1954
|
try {
|
|
1695
1955
|
sessionCreate = await fetchJsonFirst(candidates, { timeoutMs: 8000 });
|
|
1696
1956
|
} catch (err) {
|
|
1957
|
+
try {
|
|
1958
|
+
const reusable = await findReusableSession(upstreamUrl, id, sessionName);
|
|
1959
|
+
if (reusable) return json(res, 200, sessionResponseFromRow(reusable));
|
|
1960
|
+
} catch {
|
|
1961
|
+
// Preserve the original create failure; reuse is a recovery path only.
|
|
1962
|
+
}
|
|
1697
1963
|
return json(res, 409, {
|
|
1698
1964
|
error: 'agent_not_registered',
|
|
1699
1965
|
message: 'The instance is visible in inventory, but its agent has not registered yet; PTY sessions are not ready.',
|
|
@@ -1701,6 +1967,15 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1701
1967
|
});
|
|
1702
1968
|
}
|
|
1703
1969
|
const { status, body } = sessionCreate;
|
|
1970
|
+
if (status < 200 || status >= 300) {
|
|
1971
|
+
try {
|
|
1972
|
+
const reusable = await findReusableSession(upstreamUrl, id, sessionName);
|
|
1973
|
+
if (reusable) return json(res, 200, sessionResponseFromRow(reusable));
|
|
1974
|
+
} catch {
|
|
1975
|
+
// Return the upstream non-2xx response when no reusable session is visible.
|
|
1976
|
+
}
|
|
1977
|
+
return json(res, status, body);
|
|
1978
|
+
}
|
|
1704
1979
|
const wsBase = upstreamUrl.replace(/^http/i, 'ws');
|
|
1705
1980
|
const sessionId = body.id ?? body.session_id ?? body.sessionId;
|
|
1706
1981
|
if (status >= 200 && status < 300 && !sessionId && !body.attach_url && !body.attachUrl && !body.pty_ws_url && !body.ptyWsUrl) {
|
|
@@ -1716,8 +1991,8 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1716
1991
|
}
|
|
1717
1992
|
// Same as the list path (#1671): the attach segment must be the instance
|
|
1718
1993
|
// id the executor's pty-ws route accepts, not the resolved agent name.
|
|
1719
|
-
await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId });
|
|
1720
|
-
return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
|
|
1994
|
+
await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId, session_name: sessionName });
|
|
1995
|
+
return json(res, status, { ...body, id: sessionId, session_name: body.session_name ?? body.sessionName ?? sessionName, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
|
|
1721
1996
|
}
|
|
1722
1997
|
|
|
1723
1998
|
// --- management surface (UC-012): lifecycle + task cancel ---
|
|
@@ -1729,6 +2004,11 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1729
2004
|
await appendAudit('instance.lifecycle.requested', { instance_id: decodeURIComponent(m[1]), action: m[2], status: result.status, result: result.body });
|
|
1730
2005
|
return json(res, result.status, result.body);
|
|
1731
2006
|
}
|
|
2007
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/reconnect$/)) && req.method === 'POST') {
|
|
2008
|
+
const { status, body } = await reconnectInstance(upstreamUrl, decodeURIComponent(m[1]));
|
|
2009
|
+
await appendAudit('instance.reconnect.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
|
|
2010
|
+
return json(res, status, body);
|
|
2011
|
+
}
|
|
1732
2012
|
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)$/)) && req.method === 'DELETE') {
|
|
1733
2013
|
const { status, body } = await destroyInstance(upstreamUrl, decodeURIComponent(m[1]));
|
|
1734
2014
|
await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
|
package/bridge/src/smoke.mjs
CHANGED
|
@@ -51,9 +51,9 @@ try {
|
|
|
51
51
|
const demo = sess.sessions.find((s) => s.id === 'demo-shell');
|
|
52
52
|
assert.ok(demo, 'demo-shell session present');
|
|
53
53
|
assert.match(demo.attach_url, /^ws:\/\/.*\/agents\/.*\/sessions\/demo-shell\/attach$/, 'ws attach_url shape');
|
|
54
|
-
assert.ok(demo.
|
|
55
|
-
assert.equal(demo.
|
|
56
|
-
assert.equal(demo.
|
|
54
|
+
assert.ok(demo.liveness.replay_newest_seq >= 3, 'demo session has a seeded transcript');
|
|
55
|
+
assert.equal(demo.session_class, 'direct', 'demo session class');
|
|
56
|
+
assert.equal(demo.session_backend, 'native', 'demo session backend');
|
|
57
57
|
assert.equal(demo.role_policy, 'observe-default', 'session role policy');
|
|
58
58
|
|
|
59
59
|
const qemuDedup = normalizeSessionRows({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwg/cockpit",
|
|
3
|
-
"version": "2026.7.
|
|
3
|
+
"version": "2026.7.12",
|
|
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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"build:web": "npm --prefix web install --no-audit --no-fund && npm --prefix web run build",
|
|
44
44
|
"pack:dry": "npm pack --dry-run",
|
|
45
45
|
"publish:dry": "npm publish --dry-run --access public",
|
|
46
|
-
"smoke": "node mock-executor/src/smoke.mjs && node bridge/src/smoke.mjs && node shell-core/smoke.mjs",
|
|
46
|
+
"smoke": "node mock-executor/src/smoke.mjs && node bridge/src/smoke.mjs && node shell-core/smoke.mjs && node vscode/smoke.mjs",
|
|
47
47
|
"poc": "node poc/kill-bridge-isolation.mjs && node poc/security-checks.mjs",
|
|
48
48
|
"test:web": "npm --prefix web run typecheck && npm --prefix web run test",
|
|
49
49
|
"test": "npm run smoke && npm run poc",
|
package/runtime-docs/README.md
CHANGED
|
@@ -26,8 +26,9 @@ Every shell (browser, VS Code, Tauri) resolves the Bridge the same way — see
|
|
|
26
26
|
## Security
|
|
27
27
|
|
|
28
28
|
- The per-launch token is written to the OS credential backend when one is available:
|
|
29
|
-
macOS Keychain (`security`), Windows Credential Manager
|
|
30
|
-
(`secret-tool`), or opt-in KDE Wallet
|
|
29
|
+
macOS Keychain (`security`), Windows Credential Manager via PowerShell
|
|
30
|
+
PasswordVault, Linux libsecret (`secret-tool`), or opt-in KDE Wallet
|
|
31
|
+
(`AIWG_COCKPIT_ENABLE_KWALLET=1`).
|
|
31
32
|
- `bridge.json` holds **only the overlay's own per-launch token or token reference** —
|
|
32
33
|
never a provider or stack credential (verified by
|
|
33
34
|
`apps/cockpit/poc/security-checks.mjs`, property I1). Set
|