@aiwg/cockpit 2026.7.15 → 2026.7.17

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 CHANGED
@@ -121,7 +121,7 @@ operator / CLI: aiwg cockpit
121
121
  │ · user asset library: clone/import/delete (never writes AIWG)│
122
122
  │ · serves the built React app (token-injected) │
123
123
  └─────────────────────────────────────────────────────────────┘
124
- │ proxies / sources ▲ loads /?token=…
124
+ │ authenticated proxy ▲ loads the token-gated shell
125
125
  ▼ │
126
126
  agentic-sandbox executor ┌──────┴──────┬───────────────┐
127
127
  browser VS Code webview Tauri window
@@ -129,8 +129,10 @@ operator / CLI: aiwg cockpit
129
129
  ```
130
130
 
131
131
  - **Control plane** (lifecycle, approvals, actions) goes through the gated Bridge.
132
- - **Data plane** (the pty session stream) connects browser→executor directly via the
133
- `attach_url` the Bridge issues (WS masking differs per direction).
132
+ - **Data plane** (the PTY session stream) also goes through a Bridge-owned
133
+ `attach_url`. The browser presents only its per-launch Cockpit token; the
134
+ Bridge keeps the long-lived executor credential and authenticates the upstream
135
+ WebSocket upgrade.
134
136
 
135
137
  ## Surfaces (tabs)
136
138
 
@@ -184,6 +186,19 @@ approval-response decisions (the web UI additionally records action injections
184
186
  as operator intents); bearer material and provider credentials are redacted
185
187
  before write.
186
188
 
189
+ For an executor with operator bearer authentication enabled, store the selected
190
+ least-privilege token in a mode-600 file and point the Bridge at the file:
191
+
192
+ ```bash
193
+ AIWG_COCKPIT_EXECUTOR_TOKEN_FILE=/protected/path/cockpit-executor.token \
194
+ AIWG_COCKPIT_EXECUTOR_URL=http://127.0.0.1:8122 \
195
+ aiwg cockpit
196
+ ```
197
+
198
+ The file contains one token. It is re-read for rotation, never copied into the
199
+ browser, argv, URLs, reports, or audit records, and fails closed when its POSIX
200
+ permissions allow group/other access.
201
+
187
202
  ## Run (dev/test, against a real agentic-sandbox executor)
188
203
 
189
204
  One command (#1634) — prefers a reachable real executor, builds the web UI if
@@ -300,6 +315,7 @@ Tests run **at stages** — committed harnesses, never `/tmp` rigs (#1635):
300
315
  |---|---|---|---|
301
316
  | **Unit / integration** | `npm --prefix apps/cockpit run check` · `npx vitest run test/integration/cockpit-bridge.test.js` | **mock** (automated-test-only) | always |
302
317
  | **Dev e2e** (full control-plane chain: health→inventory→create session→attach) | `npm run e2e:cockpit-dev` | **real**, safe-skip when absent | non-blocking |
318
+ | **Daily Linux operator gate** (protected auth + host/container + recovery + upgrade/rollback, #1842) | `npm run uat:cockpit-daily` | **real**, required/fail-closed | operator-scheduled |
303
319
  | **Release matrix** (host/docker/vm + provider workload, #1621) | `npm run uat:cockpit-live:matrix` | **real**, all three families | release gate |
304
320
 
305
321
  ```bash
@@ -308,9 +324,17 @@ npx vitest run test/integration/cockpit-bridge.test.js # Bridge contract + moc
308
324
  npx vitest run test/smoke/cockpit-base-footprint.test.js # base-npm guard (CI)
309
325
  npm run e2e:cockpit-dev # dev full-system e2e — real executor, skips cleanly
310
326
  npm run uat:cockpit-live # opt-in real sandbox posture gate
327
+ npm run uat:cockpit-daily # required Linux daily gate (#1842)
311
328
  npm run uat:cockpit-live:matrix # required host/docker/vm live matrix (#1621)
312
329
  ```
313
330
 
331
+ The daily gate's approvals, immutable-version inputs, operator hook contract,
332
+ host/container working-directory expectations, cleanup boundary, and report
333
+ schema are documented in
334
+ [Cockpit Daily Linux Operator Gate](../../docs/cockpit/daily-operator-gate.md).
335
+ VM and Apple remain reported preview tiers and do not block the first Linux
336
+ supported result.
337
+
314
338
  The React UI is also browser-verified per surface (see `.playwright-mcp/cockpit-*.png`).
315
339
  Conformance (`agentic-sandbox-conformance`) was 33 pass / 0 fail / 17 skip; the
316
340
  Bridge-only additions since (session-create, library) don't touch the conformant
@@ -322,7 +346,7 @@ reason when no live sandbox is reachable so ordinary CI stays deterministic. Set
322
346
  `AIWG_COCKPIT_LIVE_REQUIRED=1` for release/local validation where the live gate
323
347
  must be green. The command writes `test-results/cockpit-live-uat.json` and
324
348
  `test-results/cockpit-live-uat.md` by default; set
325
- `AIWG_COCKPIT_LIVE_REPORT=.aiwg/testing/cockpit-live-uat-<date>` when the report
349
+ `AIWG_COCKPIT_LIVE_REPORT=.aiwg/testing/outputs/cockpit-live-uat-<date>` when the report
326
350
  should be kept as a project artifact. If the upstream
327
351
  `agentic-sandbox-conformance` harness is also run, set
328
352
  `AIWG_SANDBOX_CONFORMANCE_REPORT=<path>` so the Cockpit live report links the
@@ -346,10 +370,17 @@ The stricter matrix gate for #1621 is intentionally separate from the mock lane:
346
370
 
347
371
  ```bash
348
372
  AIWG_COCKPIT_EXECUTOR_URL=http://127.0.0.1:<real-executor-port> \
373
+ AIWG_COCKPIT_EXECUTOR_TOKEN_FILE=/protected/path/cockpit-executor.token \
349
374
  AIWG_COCKPIT_LIVE_PROVIDER=codex \
350
375
  npm run uat:cockpit-live:matrix
351
376
  ```
352
377
 
378
+ The token-file line is required when the executor has operator bearer auth
379
+ enabled and may be omitted only for an explicitly unauthenticated local
380
+ compatibility executor. The UAT uses the file for its direct readiness probes
381
+ and passes the same reference to the Bridge; report output records only whether
382
+ auth was configured, never the path or credential.
383
+
353
384
  Use `AIWG_COCKPIT_LIVE_PROVIDER=claude` instead when the live workload should
354
385
  exercise the pre-authenticated Claude session.
355
386
 
@@ -367,6 +398,9 @@ than only proving shell plumbing or provider login. Set
367
398
  `AIWG_COCKPIT_LIVE_DISCOVERY_EXPECT=<capability-name>` to validate a different
368
399
  discovered framework capability, or `AIWG_COCKPIT_LIVE_WORKLOAD=<prompt>` to
369
400
  replace the full prompt while still satisfying the marker and discovery checks.
401
+ Custom prompts must request the `AIWG_COCKPIT` and `_LIVE_OK` fragments without
402
+ containing the concatenated marker literally; this prevents terminal command
403
+ echo from being mistaken for provider output.
370
404
  Set `AIWG_COCKPIT_LIVE_MATRIX_TARGETS=host` only for scoped rehearsal/evidence
371
405
  when Docker/container or VM are intentionally out of scope; the default remains
372
406
  `host,container,vm` for the release matrix. To prove controller-side PTY command
@@ -427,16 +461,16 @@ Known state as of the 2026-06-19 host live run:
427
461
  real mTLS-registered host agent and a managed `tmux` session.
428
462
  - Codex launched inside that managed host session and returned
429
463
  `AIWG_COCKPIT_LIVE_OK` plus `issue-audit`; evidence lives at
430
- `.aiwg/testing/cockpit-real-codex-matrix-2026-06-19.md/.json`.
464
+ `.aiwg/testing/outputs/cockpit-real-codex-matrix-2026-06-19.md/.json`.
431
465
  - Cockpit also proved direct PTY command injection and mutation on the same real
432
466
  host path with `AIWG_COCKPIT_LIVE_MATRIX_TARGETS=host` and
433
467
  `AIWG_COCKPIT_LIVE_MUTATION_FILE`; evidence lives at
434
- `.aiwg/testing/cockpit-real-codex-mutation-2026-06-19.md/.json`, and the
468
+ `.aiwg/testing/outputs/cockpit-real-codex-mutation-2026-06-19.md/.json`, and the
435
469
  verified mutation artifact is
436
- `.aiwg/testing/cockpit-pty-mutation-2026-06-19.txt`.
470
+ `.aiwg/testing/outputs/cockpit-pty-mutation-2026-06-19.txt`.
437
471
  - Claude launched inside the same real host-session path and also returned
438
472
  `AIWG_COCKPIT_LIVE_OK` plus `issue-audit`; evidence lives at
439
- `.aiwg/testing/cockpit-real-claude-matrix-2026-06-19.md/.json`.
473
+ `.aiwg/testing/outputs/cockpit-real-claude-matrix-2026-06-19.md/.json`.
440
474
  - The previous Claude login-required blocker remains linked as
441
475
  roctinam/agentic-sandbox#499 for upstream regression tracking, but it was not
442
476
  reproduced by this isolated host proof.
@@ -448,7 +482,7 @@ Known state as of the 2026-06-19 host live run:
448
482
  the #561 transport regression are closed. Re-validated Cockpit-side against
449
483
  `v2026.6.34`: VM matrix PASS — provision → vsock enroll → boot-ready → provider
450
484
  workload → clean destroy. Evidence:
451
- `.aiwg/testing/cockpit-vm-vsock-2026-06-27.md/.json`.
485
+ `.aiwg/testing/outputs/cockpit-vm-vsock-2026-06-27.md/.json`.
452
486
  - Instance **transport posture + host-daemon** now surface from the sandbox side
453
487
  (`dd97529 fix(admin-v2): expose instance transport posture`, plus `#611`
454
488
  host-runtime session listing). Re-validated Cockpit-side against `v2026.7.4`
@@ -459,7 +493,7 @@ Known state as of the 2026-06-19 host live run:
459
493
  enrolled instance now renders real posture). Session-list returns
460
494
  cleanly (no 502 — the `#140`/`#611` endpoints are live). Runtime coverage
461
495
  banner `host ✓ · docker ✓ · vm ✓`. Evidence:
462
- `.aiwg/testing/cockpit-7.4-transport-verify-2026-07-09.md` +
496
+ `.aiwg/testing/outputs/cockpit-7.4-transport-verify-2026-07-09.md` +
463
497
  `.aiwg/working/cockpit-7.4-inventory-2026-07-09.png`.
464
498
  - Stale Docker/container agent recovery is wired through the Bridge and UI
465
499
  (2026-07-11): stale running instances remain visible as `agent unreachable`,
@@ -6,10 +6,12 @@
6
6
  // Real Bridge grows: registry/discover/index binding, per-instance A2A, pty I/O,
7
7
  // per-launch token + OS-keychain (roctinam/aiwg#1595).
8
8
  import http from 'node:http';
9
+ import https from 'node:https';
9
10
  import { spawn } from 'node:child_process';
10
11
  import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
11
12
  import { existsSync } from 'node:fs';
12
13
  import { randomBytes, timingSafeEqual } from 'node:crypto';
14
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
15
  import { homedir } from 'node:os';
14
16
  import { fileURLToPath } from 'node:url';
15
17
  import { dirname, join, basename, extname, resolve, sep } from 'node:path';
@@ -26,6 +28,7 @@ const EXECUTOR_URL =
26
28
  const ALLOW_MOCK_EXECUTOR = process.env.AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR === '1';
27
29
  const AUTOSTART_EXECUTOR = process.env.AIWG_COCKPIT_AUTOSTART_EXECUTOR !== '0';
28
30
  const EXECUTOR_COMMAND = process.env.AIWG_COCKPIT_EXECUTOR_COMMAND ?? '';
31
+ const EXECUTOR_TOKEN_FILE = process.env.AIWG_COCKPIT_EXECUTOR_TOKEN_FILE ?? '';
29
32
  const RUNTIME_DIR = join(homedir(), '.aiwg', 'cockpit', 'runtime');
30
33
  const auditDir = () => process.env.AIWG_COCKPIT_AUDIT_DIR || join(homedir(), '.aiwg', 'cockpit', 'audit');
31
34
  const auditLog = () => join(auditDir(), 'events.jsonl');
@@ -35,6 +38,45 @@ const WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
35
38
  const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2', '.map': 'application/json' };
36
39
  const CAPABILITY_TYPES = new Set(['skill', 'agent', 'command', 'rule', 'flow']);
37
40
  const mcSessionsDir = () => join(process.cwd(), '.aiwg', 'ralph-external', 'mc', 'sessions');
41
+ const executorRequestContext = new AsyncLocalStorage();
42
+
43
+ function executorAuthError(code, message, cause) {
44
+ const err = new Error(message, cause ? { cause } : undefined);
45
+ err.code = code;
46
+ return err;
47
+ }
48
+
49
+ async function resolveExecutorBearer(tokenFile) {
50
+ if (!tokenFile) return '';
51
+ const path = expandHome(String(tokenFile));
52
+ let metadata;
53
+ try {
54
+ metadata = await stat(path);
55
+ } catch (cause) {
56
+ throw executorAuthError('executor_credential_unavailable', 'executor credential file is unavailable', cause);
57
+ }
58
+ if (!metadata.isFile()) {
59
+ throw executorAuthError('executor_credential_invalid', 'executor credential path is not a regular file');
60
+ }
61
+ if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
62
+ throw executorAuthError('executor_credential_permissions', 'executor credential file must not be accessible by group or other users');
63
+ }
64
+ const token = String(await readFile(path, 'utf8')).trim();
65
+ if (!token || /[\r\n]/.test(token)) {
66
+ throw executorAuthError('executor_credential_invalid', 'executor credential file must contain exactly one non-empty bearer token');
67
+ }
68
+ return token;
69
+ }
70
+
71
+ async function executorFetch(target, init = {}) {
72
+ const context = executorRequestContext.getStore();
73
+ const headers = new Headers(init.headers);
74
+ if (context && new URL(target).origin === context.executorOrigin && !headers.has('authorization')) {
75
+ const token = await resolveExecutorBearer(context.executorTokenFile);
76
+ if (token) headers.set('authorization', `Bearer ${token}`);
77
+ }
78
+ return fetch(target, { ...init, headers });
79
+ }
38
80
 
39
81
  /** Serve a static file from the built web app, sandboxed to WEB_DIST. Returns true if served. */
40
82
  async function serveDistFile(res, relPath) {
@@ -352,8 +394,14 @@ async function readJsonBody(req) {
352
394
 
353
395
  /** Forward a control-plane call to the executor admin surface, relaying status + body. */
354
396
  async function proxy(res, method, target) {
355
- const r = await fetch(target, { method });
397
+ const r = await executorFetch(target, { method });
356
398
  const body = await r.json().catch(() => ({}));
399
+ if (r.status === 401 || r.status === 403) {
400
+ const err = new Error(`executor ${r.status === 401 ? 'authentication' : 'authorization'} failed at ${new URL(target).pathname}`);
401
+ err.code = r.status === 401 ? 'executor_unauthenticated' : 'executor_forbidden';
402
+ err.upstreamStatus = r.status;
403
+ throw err;
404
+ }
357
405
  return json(res, r.status, body);
358
406
  }
359
407
 
@@ -373,6 +421,10 @@ function isConnectionRefusedError(err) {
373
421
  return /ECONNREFUSED|connection refused/i.test(text);
374
422
  }
375
423
 
424
+ function rethrowExecutorSecurityError(err) {
425
+ if ([401, 403].includes(Number(err?.upstreamStatus)) || String(err?.code ?? '').startsWith('executor_credential_')) throw err;
426
+ }
427
+
376
428
  export async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption, timeoutMs = 0 } = {}) {
377
429
  const failures = [];
378
430
  for (const candidate of candidates) {
@@ -384,8 +436,9 @@ export async function fetchJsonFirst(candidates, { method = 'GET', headers, body
384
436
  const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
385
437
  let r;
386
438
  try {
387
- r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
439
+ r = await executorFetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
388
440
  } catch (err) {
441
+ if (String(err?.code ?? '').startsWith('executor_credential_')) throw err;
389
442
  const failure = isAbortError(err) && timeoutMs > 0
390
443
  ? `${target} -> timeout after ${timeoutMs}ms`
391
444
  : `${target} -> ${String(err?.message ?? err)}`;
@@ -398,6 +451,12 @@ export async function fetchJsonFirst(candidates, { method = 'GET', headers, body
398
451
  if (timeout) clearTimeout(timeout);
399
452
  }
400
453
  const responseBody = await r.json().catch(() => ({}));
454
+ if (r.status === 401 || r.status === 403) {
455
+ const err = new Error(`executor ${r.status === 401 ? 'authentication' : 'authorization'} failed at ${new URL(target).pathname}`);
456
+ err.code = r.status === 401 ? 'executor_unauthenticated' : 'executor_forbidden';
457
+ err.upstreamStatus = r.status;
458
+ throw err;
459
+ }
401
460
  if (r.ok) return { target, status: r.status, body: responseBody };
402
461
  failures.push(`${target} -> ${r.status}`);
403
462
  if (r.status !== 404 && r.status !== 405) return { target, status: r.status, body: responseBody, failures };
@@ -435,7 +494,7 @@ async function assertRealExecutor(executorUrl, allowMockExecutor) {
435
494
  async function probeExecutor(executorUrl) {
436
495
  for (const path of ['/healthz/http', '/healthz', '/health']) {
437
496
  try {
438
- const r = await fetch(`${executorUrl}${path}`, { signal: AbortSignal.timeout(1_500) });
497
+ const r = await executorFetch(`${executorUrl}${path}`, { signal: AbortSignal.timeout(1_500) });
439
498
  if (r.ok) return true;
440
499
  } catch {
441
500
  // Try the next health endpoint.
@@ -455,6 +514,7 @@ async function getExecutorCapabilities(executorUrl) {
455
514
  raw_status: body.status ?? body.state ?? 'unknown',
456
515
  };
457
516
  } catch (err) {
517
+ rethrowExecutorSecurityError(err);
458
518
  return {
459
519
  status: 'unreachable',
460
520
  source: null,
@@ -498,6 +558,10 @@ async function proxyFirst(res, candidates, options) {
498
558
  const { status, body } = await fetchJsonFirst(candidates, options);
499
559
  return json(res, status, body);
500
560
  } catch (err) {
561
+ if ([401, 403].includes(Number(err?.upstreamStatus))) {
562
+ return json(res, Number(err.upstreamStatus), { error: err.code, message: String(err.message) });
563
+ }
564
+ if (String(err?.code ?? '').startsWith('executor_credential_')) throw err;
501
565
  const message = String(err?.message ?? err);
502
566
  const notFound = / -> 404(?:;|$)/.test(message);
503
567
  const methodNotAllowed = / -> 405(?:;|$)/.test(message);
@@ -509,7 +573,9 @@ async function proxyFirst(res, candidates, options) {
509
573
  }
510
574
 
511
575
  async function destroyInstance(upstreamUrl, instanceId) {
512
- const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
576
+ let inventory;
577
+ try { inventory = await getInventory(upstreamUrl); }
578
+ catch (err) { rethrowExecutorSecurityError(err); inventory = { instances: [] }; }
513
579
  const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
514
580
  const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
515
581
  const dockerName = inst?.launch_context?.name;
@@ -540,6 +606,7 @@ async function destroyInstance(upstreamUrl, instanceId) {
540
606
  return result;
541
607
  }
542
608
  } catch (err) {
609
+ rethrowExecutorSecurityError(err);
543
610
  const message = String(err?.message ?? err);
544
611
  // A docker/container row with a resolvable name is still physically
545
612
  // removable even when admin-v2 has no instance record (404): fall through
@@ -634,7 +701,9 @@ async function signalVmAgentReconnect(domain) {
634
701
  const VM_RUNTIME_KINDS = ['vm', 'qemu', 'kvm'];
635
702
 
636
703
  async function reconnectInstance(upstreamUrl, instanceId) {
637
- const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
704
+ let inventory;
705
+ try { inventory = await getInventory(upstreamUrl); }
706
+ catch (err) { rethrowExecutorSecurityError(err); inventory = { instances: [] }; }
638
707
  const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
639
708
  const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
640
709
  const dockerName = inst?.launch_context?.name;
@@ -646,7 +715,8 @@ async function reconnectInstance(upstreamUrl, instanceId) {
646
715
  try {
647
716
  const result = await fetchJsonFirst(candidates, { timeoutMs: 5_000 });
648
717
  if (result.status < 400) return result;
649
- } catch {
718
+ } catch (err) {
719
+ rethrowExecutorSecurityError(err);
650
720
  // agentic-sandbox v2026.7.6 still exposes the container reconnect as an
651
721
  // in-image helper, not an HTTP endpoint. Fall through to the local-dev path.
652
722
  }
@@ -766,7 +836,8 @@ async function resolveSessionAgentId(executorUrl, instanceId) {
766
836
  const agents = await getAgentList(executorUrl);
767
837
  const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(instanceId));
768
838
  return agent?.id ?? agent?.agent_id ?? agent?.agentId ?? instanceId;
769
- } catch {
839
+ } catch (err) {
840
+ rethrowExecutorSecurityError(err);
770
841
  return instanceId;
771
842
  }
772
843
  }
@@ -1001,7 +1072,8 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
1001
1072
  loadout: instance.loadout ?? runtimeExtension.loadout,
1002
1073
  image_ref: instance.image_ref ?? runtimeExtension.image_ref,
1003
1074
  };
1004
- } catch {
1075
+ } catch (err) {
1076
+ rethrowExecutorSecurityError(err);
1005
1077
  return instance;
1006
1078
  }
1007
1079
  }
@@ -1009,7 +1081,8 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
1009
1081
  async function getRegisteredAgents(executorUrl) {
1010
1082
  try {
1011
1083
  return await getAgentList(executorUrl);
1012
- } catch {
1084
+ } catch (err) {
1085
+ rethrowExecutorSecurityError(err);
1013
1086
  return [];
1014
1087
  }
1015
1088
  }
@@ -1170,7 +1243,7 @@ async function getRunning(executorUrl) {
1170
1243
  await Promise.all(
1171
1244
  instances.filter((i) => i.state === 'running').map(async (inst) => {
1172
1245
  let tasks;
1173
- try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
1246
+ try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch (err) { rethrowExecutorSecurityError(err); return; }
1174
1247
  for (const t of tasks) {
1175
1248
  const state = taskState(t);
1176
1249
  if (!ACTIVE_TASK_STATES.has(state)) continue;
@@ -1250,7 +1323,7 @@ async function getApprovals(executorUrl, status) {
1250
1323
  await Promise.all(
1251
1324
  instances.filter((i) => i.state === 'running').map(async (inst) => {
1252
1325
  let tasks;
1253
- try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
1326
+ try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch (err) { rethrowExecutorSecurityError(err); return; }
1254
1327
  for (const t of tasks) {
1255
1328
  const approval = approvalFromTask(inst, t);
1256
1329
  if (!approval) continue;
@@ -1402,7 +1475,7 @@ async function getSessionEventRows(executorUrl, instances) {
1402
1475
  const rows = [];
1403
1476
  await Promise.all((instances ?? []).map(async (inst) => {
1404
1477
  let sessions;
1405
- try { sessions = (await getSessions(executorUrl, inst.id)).sessions; } catch { return; }
1478
+ try { sessions = (await getSessions(executorUrl, inst.id)).sessions; } catch (err) { rethrowExecutorSecurityError(err); return; }
1406
1479
  for (const session of sessions) {
1407
1480
  rows.push({
1408
1481
  id: session.id,
@@ -1492,16 +1565,13 @@ async function respondApproval(executorUrl, approvalId, decision) {
1492
1565
  const { status, body } = await fetchJsonFirst(candidates);
1493
1566
  return { status, body };
1494
1567
  } catch (e) {
1568
+ rethrowExecutorSecurityError(e);
1495
1569
  return { status: 409, body: { error: 'approval_response_failed', detail: String(e?.message ?? e) } };
1496
1570
  }
1497
1571
  }
1498
1572
 
1499
- /**
1500
- * Sessions for one instance, each with a direct attach_url. Control plane (this
1501
- * list) goes through the Bridge; the data plane (the pty stream) connects direct
1502
- * to the executor — masking differs per WS direction, so the Bridge issues the
1503
- * URL rather than proxying frames.
1504
- */
1573
+ /** Sessions for one instance. Executor attach targets are normalized here and
1574
+ * replaced with Bridge-owned proxy URLs at the request boundary. */
1505
1575
  async function getSessions(executorUrl, instanceId) {
1506
1576
  const sessionAgentId = await resolveSessionAgentId(executorUrl, instanceId);
1507
1577
  const agentIds = unique([instanceId, sessionAgentId]);
@@ -1546,6 +1616,7 @@ async function getSessionScreen(executorUrl, instanceId, sessionId) {
1546
1616
  const { body, target, status } = await fetchJsonFirst(paths);
1547
1617
  return { status, body: normalizeScreenSnapshot(body, { instanceId, sessionId, source: target }) };
1548
1618
  } catch (e) {
1619
+ rethrowExecutorSecurityError(e);
1549
1620
  return {
1550
1621
  status: 404,
1551
1622
  body: {
@@ -1663,7 +1734,8 @@ async function endSession(executorUrl, instanceId, sessionId) {
1663
1734
  let sessions = [];
1664
1735
  try {
1665
1736
  sessions = (await getSessions(executorUrl, instanceId)).sessions;
1666
- } catch {
1737
+ } catch (err) {
1738
+ rethrowExecutorSecurityError(err);
1667
1739
  // Fall back to using the supplied id directly; older executors may not list
1668
1740
  // before delete, and delete should remain useful during recovery cleanup.
1669
1741
  }
@@ -1702,10 +1774,98 @@ function sessionResponseFromRow(row) {
1702
1774
  };
1703
1775
  }
1704
1776
 
1705
- export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = ALLOW_MOCK_EXECUTOR, token } = {}) {
1777
+ function websocketCockpitToken(req) {
1778
+ const protocols = String(req.headers['sec-websocket-protocol'] ?? '')
1779
+ .split(',')
1780
+ .map((value) => value.trim())
1781
+ .filter(Boolean);
1782
+ const encoded = protocols.find((value) => value.startsWith('cockpit.'))?.slice('cockpit.'.length) ?? '';
1783
+ try { return Buffer.from(encoded, 'base64url').toString('utf8'); } catch { return ''; }
1784
+ }
1785
+
1786
+ function websocketAuthed(req, expected) {
1787
+ const presented = websocketCockpitToken(req);
1788
+ if (presented.length !== expected.length) return false;
1789
+ try { return timingSafeEqual(Buffer.from(presented), Buffer.from(expected)); } catch { return false; }
1790
+ }
1791
+
1792
+ function writeUpgradeHead(socket, response) {
1793
+ socket.write(`HTTP/1.1 ${response.statusCode} ${response.statusMessage ?? 'Switching Protocols'}\r\n`);
1794
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
1795
+ socket.write(`${response.rawHeaders[index]}: ${response.rawHeaders[index + 1]}\r\n`);
1796
+ }
1797
+ socket.write('\r\n');
1798
+ }
1799
+
1800
+ async function proxyExecutorWebsocket({ req, socket, head, target, executorTokenFile }) {
1801
+ const token = await resolveExecutorBearer(executorTokenFile);
1802
+ const requestedProtocols = String(req.headers['sec-websocket-protocol'] ?? '')
1803
+ .split(',')
1804
+ .map((value) => value.trim())
1805
+ .filter((value) => value && !value.startsWith('cockpit.'));
1806
+ const headers = {
1807
+ connection: 'Upgrade',
1808
+ upgrade: 'websocket',
1809
+ host: target.host,
1810
+ 'sec-websocket-key': req.headers['sec-websocket-key'],
1811
+ 'sec-websocket-version': req.headers['sec-websocket-version'],
1812
+ ...(req.headers['sec-websocket-extensions'] ? { 'sec-websocket-extensions': req.headers['sec-websocket-extensions'] } : {}),
1813
+ ...(requestedProtocols.length ? { 'sec-websocket-protocol': requestedProtocols.join(', ') } : {}),
1814
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
1815
+ };
1816
+ const transport = target.protocol === 'wss:' ? https : http;
1817
+ const requestTarget = new URL(target);
1818
+ requestTarget.protocol = target.protocol === 'wss:' ? 'https:' : 'http:';
1819
+ const upstreamRequest = transport.request(requestTarget, { method: 'GET', headers });
1820
+ upstreamRequest.on('upgrade', (response, upstreamSocket, upstreamHead) => {
1821
+ writeUpgradeHead(socket, response);
1822
+ if (head.length) upstreamSocket.write(head);
1823
+ if (upstreamHead.length) socket.write(upstreamHead);
1824
+ socket.pipe(upstreamSocket);
1825
+ upstreamSocket.pipe(socket);
1826
+ const closeBoth = () => {
1827
+ if (!socket.destroyed) socket.destroy();
1828
+ if (!upstreamSocket.destroyed) upstreamSocket.destroy();
1829
+ };
1830
+ socket.on('error', closeBoth);
1831
+ upstreamSocket.on('error', closeBoth);
1832
+ });
1833
+ upstreamRequest.on('response', (response) => {
1834
+ socket.write(`HTTP/1.1 ${response.statusCode ?? 502} ${response.statusMessage ?? 'Upstream Error'}\r\nConnection: close\r\n\r\n`);
1835
+ socket.destroy();
1836
+ response.resume();
1837
+ });
1838
+ upstreamRequest.on('error', () => {
1839
+ if (!socket.destroyed) socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
1840
+ });
1841
+ upstreamRequest.end();
1842
+ }
1843
+
1844
+ export function createBridge({
1845
+ executorUrl = EXECUTOR_URL,
1846
+ allowMockExecutor = ALLOW_MOCK_EXECUTOR,
1847
+ token,
1848
+ executorTokenFile = EXECUTOR_TOKEN_FILE,
1849
+ } = {}) {
1706
1850
  const upstreamUrl = executorUrl;
1707
1851
  const TOKEN = token ?? randomBytes(24).toString('hex');
1708
- const server = http.createServer(async (req, res) => {
1852
+ const executorOrigin = new URL(upstreamUrl).origin;
1853
+ const executorAddress = new URL(upstreamUrl);
1854
+ const attachTargets = new Map();
1855
+ const issueAttachUrl = (req, value) => {
1856
+ const target = new URL(String(value));
1857
+ const sameHost = target.hostname === executorAddress.hostname ||
1858
+ (isLocalHostName(target.hostname) && isLocalHostName(executorAddress.hostname));
1859
+ if (!['ws:', 'wss:'].includes(target.protocol) || !sameHost || !/^\/agents\/[^/]+\/sessions\/[^/]+\/attach$/.test(target.pathname)) {
1860
+ throw executorAuthError('executor_attach_target_refused', 'executor returned an attach URL outside the allowed PTY endpoint');
1861
+ }
1862
+ const id = randomBytes(18).toString('base64url');
1863
+ attachTargets.set(id, target);
1864
+ if (attachTargets.size > 1024) attachTargets.delete(attachTargets.keys().next().value);
1865
+ const wsProtocol = req.socket.encrypted ? 'wss:' : 'ws:';
1866
+ return `${wsProtocol}//${req.headers.host}/api/pty${target.pathname}/${id}`;
1867
+ };
1868
+ const handleRequest = async (req, res) => {
1709
1869
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
1710
1870
  try {
1711
1871
  // unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
@@ -1818,7 +1978,12 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1818
1978
  if (url.pathname === '/api/sessions') {
1819
1979
  const inst = url.searchParams.get('instance');
1820
1980
  if (!inst) return json(res, 400, { error: 'instance_required' });
1821
- return json(res, 200, await getSessions(upstreamUrl, inst));
1981
+ const result = await getSessions(upstreamUrl, inst);
1982
+ result.sessions = result.sessions.map((session) => ({
1983
+ ...session,
1984
+ attach_url: issueAttachUrl(req, session.attach_url),
1985
+ }));
1986
+ return json(res, 200, result);
1822
1987
  }
1823
1988
  if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)$/)) && req.method === 'DELETE') {
1824
1989
  const { status, body } = await endSession(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
@@ -1992,7 +2157,13 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1992
2157
  // Same as the list path (#1671): the attach segment must be the instance
1993
2158
  // id the executor's pty-ws route accepts, not the resolved agent name.
1994
2159
  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` });
2160
+ const executorAttachUrl = attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach`;
2161
+ return json(res, status, {
2162
+ ...body,
2163
+ id: sessionId,
2164
+ session_name: body.session_name ?? body.sessionName ?? sessionName,
2165
+ attach_url: issueAttachUrl(req, executorAttachUrl),
2166
+ });
1996
2167
  }
1997
2168
 
1998
2169
  // --- management surface (UC-012): lifecycle + task cancel ---
@@ -2032,7 +2203,12 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
2032
2203
  if (url.pathname === '/api/cost' && req.method === 'GET')
2033
2204
  return proxy(res, 'GET', `${upstreamUrl}/admin/cost`);
2034
2205
 
2035
- if (url.pathname === '/api/health') return json(res, 200, { status: 'ok', executor_url: upstreamUrl, mock_executor_allowed: allowMockExecutor });
2206
+ if (url.pathname === '/api/health') return json(res, 200, {
2207
+ status: 'ok',
2208
+ executor_url: upstreamUrl,
2209
+ mock_executor_allowed: allowMockExecutor,
2210
+ executor_auth_configured: Boolean(executorTokenFile),
2211
+ });
2036
2212
  if (url.pathname === '/' || url.pathname === '/index.html') {
2037
2213
  const distIndex = join(WEB_DIST, 'index.html');
2038
2214
  const src = existsSync(distIndex) ? distIndex : join(__dir, 'public', 'index.html');
@@ -2053,9 +2229,39 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
2053
2229
  }
2054
2230
  json(res, 404, { error: 'not_found', path: url.pathname });
2055
2231
  } catch (err) {
2056
- json(res, 502, { error: 'bridge_upstream_error', message: String(err?.message ?? err) });
2232
+ const status = Number(err?.upstreamStatus) || 502;
2233
+ json(res, status, { error: err?.code ?? 'bridge_upstream_error', message: String(err?.message ?? err) });
2057
2234
  }
2058
- });
2235
+ };
2236
+ const server = http.createServer((req, res) => executorRequestContext.run(
2237
+ { executorOrigin, executorTokenFile },
2238
+ () => handleRequest(req, res),
2239
+ ));
2240
+ server.on('upgrade', (req, socket, head) => executorRequestContext.run(
2241
+ { executorOrigin, executorTokenFile },
2242
+ async () => {
2243
+ try {
2244
+ const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
2245
+ const match = url.pathname.match(/^\/api\/pty\/agents\/[^/]+\/sessions\/[^/]+\/attach\/([^/]+)$/);
2246
+ if (!match || !validBrowserOrigin(req)) {
2247
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
2248
+ return;
2249
+ }
2250
+ if (!websocketAuthed(req, TOKEN)) {
2251
+ socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
2252
+ return;
2253
+ }
2254
+ const target = attachTargets.get(match[1]);
2255
+ if (!target || url.pathname !== `/api/pty${target.pathname}/${match[1]}`) {
2256
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
2257
+ return;
2258
+ }
2259
+ await proxyExecutorWebsocket({ req, socket, head, target, executorTokenFile });
2260
+ } catch {
2261
+ if (!socket.destroyed) socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
2262
+ }
2263
+ },
2264
+ ));
2059
2265
  server.cockpitToken = TOKEN; // exposed for shells/tests
2060
2266
  return server;
2061
2267
  }
@@ -7,7 +7,8 @@ import { createBridge, 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));
10
- const executorUrl = `http://127.0.0.1:${mock.address().port}`;
10
+ const executorPort = mock.address().port;
11
+ const executorUrl = `http://127.0.0.1:${executorPort}`;
11
12
 
12
13
  const bridge = createBridge({ executorUrl, allowMockExecutor: true });
13
14
  await new Promise((r) => bridge.listen(0, '127.0.0.1', r));
@@ -35,6 +36,16 @@ try {
35
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}`);
36
37
  assert.ok(['vm', 'container', 'host', 'wasm-edge'].includes(i0.runtime), 'runtime kind');
37
38
 
39
+ // A transient executor outage must not poison Bridge state or require a
40
+ // Bridge restart. Every poll is a fresh upstream request, so the same Bridge
41
+ // reports the gap and resumes inventory as soon as the executor returns.
42
+ await new Promise((resolve) => mock.close(resolve));
43
+ assert.equal((await f('/api/inventory')).status, 502, 'transient executor drop -> 502');
44
+ await new Promise((resolve) => mock.listen(executorPort, '127.0.0.1', resolve));
45
+ const recoveredInventory = await f('/api/inventory');
46
+ assert.equal(recoveredInventory.status, 200, 'same Bridge resumes after executor returns');
47
+ assert.equal((await recoveredInventory.json()).count, 4, 'recovered inventory is complete');
48
+
38
49
  // running board: seeded working tasks on the running instances
39
50
  const rr = await f("/api/running");
40
51
  assert.equal(rr.status, 200, 'running 200');
@@ -44,13 +55,13 @@ try {
44
55
  for (const k of ['runtime_posture', 'transport']) assert.ok(k in run.running[0], `running posture field ${k}`);
45
56
  assert.equal(run.running[0].state, 'working', 'running task is working');
46
57
 
47
- // sessions: the demo pty session is listed with a direct ws attach_url
58
+ // sessions: the demo pty session is listed with a Bridge-owned ws attach_url
48
59
  const sr = await f("/api/sessions?instance=550e8400-e29b-41d4-a716-446655440000");
49
60
  assert.equal(sr.status, 200, 'sessions 200');
50
61
  const sess = await sr.json();
51
62
  const demo = sess.sessions.find((s) => s.id === 'demo-shell');
52
63
  assert.ok(demo, 'demo-shell session present');
53
- assert.match(demo.attach_url, /^ws:\/\/.*\/agents\/.*\/sessions\/demo-shell\/attach$/, 'ws attach_url shape');
64
+ assert.match(demo.attach_url, /^ws:\/\/.*\/api\/pty\/agents\/.*\/sessions\/demo-shell\/attach\/[A-Za-z0-9_-]+$/, 'ws attach_url shape');
54
65
  assert.ok(demo.liveness.replay_newest_seq >= 3, 'demo session has a seeded transcript');
55
66
  assert.equal(demo.session_class, 'direct', 'demo session class');
56
67
  assert.equal(demo.session_backend, 'native', 'demo session backend');
@@ -168,7 +179,7 @@ try {
168
179
  // start a session (onboarding primary verb): create + issue a ws attach_url
169
180
  const started = await (await f('/api/instances/550e8400-e29b-41d4-a716-446655440000/sessions', { method: 'POST' })).json();
170
181
  assert.match(started.id ?? '', /^sess-/, 'start-session returns a new session id');
171
- assert.match(started.attach_url ?? '', /\/sessions\/sess-[^/]+\/attach$/, 'start-session issues a ws attach_url');
182
+ assert.match(started.attach_url ?? '', /\/sessions\/sess-[^/]+\/attach\/[A-Za-z0-9_-]+$/, 'start-session issues a proxied ws attach_url');
172
183
 
173
184
  // app shell served with the per-launch token injected (React build if present, else
174
185
  // the legacy fallback — both carry the title + token)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.7.15",
3
+ "version": "2026.7.17",
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",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
2
+ import { render, screen, cleanup, waitFor, fireEvent, act } from '@testing-library/react';
3
3
  import { App, waitForSessionReady } from './App';
4
4
 
5
5
  // Rendered-DOM coverage (the a11y assertions deferred from T2, and a guard against the
@@ -213,6 +213,49 @@ describe('App shell (rendered DOM)', () => {
213
213
  expect(screen.getByText(/host ✓ · docker - · vm -/)).toBeTruthy();
214
214
  });
215
215
 
216
+ it('shows reconnecting and restores all live views after a transient drop without a page refresh (#1763)', async () => {
217
+ vi.useFakeTimers();
218
+ try {
219
+ let executorAvailable = true;
220
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
221
+ const url = String(input);
222
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
223
+ if (url.includes('/api/inventory')) {
224
+ return executorAvailable
225
+ ? jsonResponse({ count: 1, fetched_at: new Date().toISOString(), instances: [instance('host-1', 'host', 'Codex host')] })
226
+ : errorResponse(502);
227
+ }
228
+ if (url.includes('/api/running')) return executorAvailable ? jsonResponse({ count: 1, running: [] }) : errorResponse(502);
229
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
230
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
231
+ return jsonResponse({});
232
+ }) as typeof fetch;
233
+
234
+ render(<App />);
235
+ await act(async () => { await Promise.resolve(); await Promise.resolve(); });
236
+ expect(screen.getByText('Bridge live')).toBeTruthy();
237
+ expect(screen.getByText('1 stacks')).toBeTruthy();
238
+ const sessionCallsBeforeDrop = vi.mocked(globalThis.fetch).mock.calls
239
+ .filter(([input]) => String(input).includes('/api/sessions?instance=')).length;
240
+
241
+ executorAvailable = false;
242
+ await act(async () => { await vi.advanceTimersByTimeAsync(15_000); });
243
+ expect(screen.getByText('Reconnecting…')).toBeTruthy();
244
+ expect(screen.getByTitle(/showing last-known status/i)).toBeTruthy();
245
+ expect(screen.getByText('1 stacks')).toBeTruthy();
246
+
247
+ executorAvailable = true;
248
+ await act(async () => { await vi.advanceTimersByTimeAsync(1_000); });
249
+ expect(screen.getByText('Bridge live')).toBeTruthy();
250
+ expect(screen.queryByText('Reconnecting…')).toBeNull();
251
+ expect(globalThis.fetch).toHaveBeenCalledWith(expect.stringContaining('/api/running'), expect.anything());
252
+ expect(vi.mocked(globalThis.fetch).mock.calls
253
+ .filter(([input]) => String(input).includes('/api/sessions?instance=')).length).toBeGreaterThan(sessionCallsBeforeDrop);
254
+ } finally {
255
+ vi.useRealTimers();
256
+ }
257
+ });
258
+
216
259
  it('stays connected when the executor exposes no running/approvals admin surface (#1638)', async () => {
217
260
  // Real agentic-sandbox has no /running or /approvals admin route, so those
218
261
  // Bridge endpoints error. Inventory + health succeed. The header must stay
package/web/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, type ReactNode } from 'react';
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
2
  import { useSession } from './useSession';
3
3
  import { api, TOKEN } from './api';
4
4
  import type { Approval, Instance, ResponseNeeded } from './types';
@@ -41,6 +41,7 @@ interface ChromeStatus {
41
41
  container: boolean;
42
42
  vm: boolean;
43
43
  }
44
+ type ConnectionState = 'checking' | 'live' | 'reconnecting';
44
45
 
45
46
  const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
46
47
 
@@ -54,6 +55,9 @@ export function App() {
54
55
  const registryResponses = registryResponseNeededItems(sessionRegistry).filter((response) => response.id !== `pty:${sessionRegistry.activeKey}`);
55
56
  const [composer, setComposer] = useState('');
56
57
  const [chrome, setChrome] = useState<ChromeStatus | null>(null);
58
+ const [connectionState, setConnectionState] = useState<ConnectionState>('checking');
59
+ const reconnectPendingRef = useRef(false);
60
+ const eventsDownRef = useRef(false);
57
61
  const [startOpen, setStartOpen] = useState(false);
58
62
  const [startInst, setStartInst] = useState<string | undefined>(undefined);
59
63
  const [launchOpen, setLaunchOpen] = useState(false);
@@ -61,6 +65,11 @@ export function App() {
61
65
 
62
66
  useEffect(() => {
63
67
  let cancelled = false;
68
+ let timer: number | undefined;
69
+ let retryMs = 1_000;
70
+ const schedule = (ms: number) => {
71
+ timer = window.setTimeout(load, ms);
72
+ };
64
73
  const load = async () => {
65
74
  try {
66
75
  // Health + inventory decide "Bridge live". Running + approvals are
@@ -87,20 +96,52 @@ export function App() {
87
96
  container: families.includes('container'),
88
97
  vm: families.includes('vm'),
89
98
  });
99
+ if (!eventsDownRef.current) {
100
+ const recovered = reconnectPendingRef.current;
101
+ reconnectPendingRef.current = false;
102
+ setConnectionState('live');
103
+ // One recovery pulse refreshes every mounted live-data view. This is
104
+ // deliberately separate from their own polling/backoff, so Running,
105
+ // Missions, Sessions, and Inventory converge together after a blip.
106
+ if (recovered) setRefreshTick((t) => t + 1);
107
+ }
108
+ retryMs = 1_000;
109
+ if (!cancelled) schedule(15_000);
90
110
  } catch {
91
- if (!cancelled) setChrome(null);
111
+ if (!cancelled) {
112
+ // Preserve last-known chrome while making its staleness explicit.
113
+ // Retry quickly, then back off to the normal 15 s poll ceiling.
114
+ reconnectPendingRef.current = true;
115
+ setConnectionState('reconnecting');
116
+ schedule(retryMs);
117
+ retryMs = Math.min(retryMs * 2, 15_000);
118
+ }
92
119
  }
93
120
  };
94
121
  load();
95
- const timer = window.setInterval(load, 15_000);
96
- return () => { cancelled = true; window.clearInterval(timer); };
122
+ return () => {
123
+ cancelled = true;
124
+ if (timer !== undefined) window.clearTimeout(timer);
125
+ };
97
126
  }, [session.responseNeeded.needed, refreshTick, registryResponses.length]);
98
127
 
99
128
  useEffect(() => {
100
129
  if (typeof EventSource === 'undefined' || !TOKEN) return;
101
130
  const events = new EventSource(`/api/events?token=${encodeURIComponent(TOKEN)}`);
102
- events.addEventListener('cockpit.refresh', () => setRefreshTick((t) => t + 1));
103
- events.onerror = () => undefined;
131
+ const refresh = () => {
132
+ eventsDownRef.current = false;
133
+ setRefreshTick((t) => t + 1);
134
+ };
135
+ events.onopen = refresh;
136
+ events.addEventListener('cockpit.refresh', refresh);
137
+ events.onerror = () => {
138
+ eventsDownRef.current = true;
139
+ reconnectPendingRef.current = true;
140
+ setConnectionState('reconnecting');
141
+ // EventSource reconnects itself. Pulse the REST path now as well so a
142
+ // Bridge/executor drop does not wait for the normal poll interval.
143
+ setRefreshTick((t) => t + 1);
144
+ };
104
145
  return () => events.close();
105
146
  }, []);
106
147
 
@@ -162,8 +203,13 @@ export function App() {
162
203
  <span className="mark" aria-hidden="true">◆</span>
163
204
  <h1>AIWG&nbsp;Cockpit</h1>
164
205
  </div>
165
- <div className="top-status" aria-label="Cockpit status">
166
- <span className={`health-pill ${chrome ? 'ok' : 'warn'}`}>{chrome ? 'Bridge live' : 'Bridge checking'}</span>
206
+ <div className="top-status" aria-label="Cockpit status" aria-live="polite">
207
+ <span
208
+ className={`health-pill ${connectionState === 'live' ? 'ok' : 'warn'}`}
209
+ title={connectionState === 'reconnecting' && chrome ? 'Connection interrupted; showing last-known status while Cockpit retries.' : undefined}
210
+ >
211
+ {connectionState === 'live' ? 'Bridge live' : connectionState === 'reconnecting' ? 'Reconnecting…' : 'Bridge checking'}
212
+ </span>
167
213
  {chrome && (
168
214
  <>
169
215
  <span className="executor-pill" title={chrome.executor}>{chrome.executor}</span>
@@ -193,7 +239,7 @@ export function App() {
193
239
  <Panel id="missions" tab={tab}><Missions refreshTick={refreshTick} /></Panel>
194
240
  {/* Sessions stays mounted so the WebSocket survives tab switches */}
195
241
  <section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" hidden={tab !== 'sessions'}>
196
- <Sessions session={session} composer={composer} setComposer={setComposer} onRequestStart={requestStart} />
242
+ <Sessions session={session} composer={composer} setComposer={setComposer} onRequestStart={requestStart} refreshTick={refreshTick} />
197
243
  </section>
198
244
  <Panel id="approvals" tab={tab}><Approvals refreshTick={refreshTick} responses={[...registryResponses, ...(session.responseNeeded.needed ? [sessionResponse(session)] : [])]} goSessions={() => setTab('sessions')} /></Panel>
199
245
  <Panel id="explore" tab={tab}><Explore /></Panel>
@@ -7,7 +7,14 @@ import type { SessionApi } from '../useSession';
7
7
  import { markRegistrySessionViewed, sessionRegistryKeyFor, setRegistryActiveSession, upsertRegistrySessions, useSessionRegistry } from '../sessionRegistry';
8
8
  import { useSessionSnapshotMonitor } from '../sessionMonitor';
9
9
 
10
- export function Sessions({ session, composer, setComposer, onRequestStart, refreshMs = 5_000 }: { session: SessionApi; composer: string; setComposer: (v: string) => void; onRequestStart: (instanceId?: string) => void; refreshMs?: number }) {
10
+ export function Sessions({ session, composer, setComposer, onRequestStart, refreshMs = 5_000, refreshTick = 0 }: {
11
+ session: SessionApi;
12
+ composer: string;
13
+ setComposer: (v: string) => void;
14
+ onRequestStart: (instanceId?: string) => void;
15
+ refreshMs?: number;
16
+ refreshTick?: number;
17
+ }) {
11
18
  const [instances, setInstances] = useState<Instance[]>([]);
12
19
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
13
20
  const [instId, setInstId] = useState('');
@@ -112,7 +119,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
112
119
  cancelled = true;
113
120
  if (timer !== undefined) window.clearTimeout(timer);
114
121
  };
115
- }, [endingSession, loadSessions, refreshInventory, refreshMs]);
122
+ }, [endingSession, loadSessions, refreshInventory, refreshMs, refreshTick]);
116
123
 
117
124
  useEffect(() => {
118
125
  if (!instId) return;
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { Terminal } from '@xterm/xterm';
3
3
  import { FitAddon } from '@xterm/addon-fit';
4
4
  import type { Role } from './types';
5
+ import { TOKEN } from './api';
5
6
 
6
7
  // The pty session connections, lifted to App so the Sessions tab renders them and the
7
- // Actions tab can inject into the active one. Data plane is browser→executor (the
8
- // attach_url the Bridge issues); control plane stays on the Bridge.
8
+ // Actions tab can inject into the active one. Both planes terminate at the Bridge:
9
+ // the Bridge proxies PTY upgrades so the browser never receives executor credentials.
9
10
  //
10
11
  // Each (instance, session) keeps its OWN persistent xterm Terminal + WebSocket, mounted
11
12
  // once and hidden (not torn down) when another session is shown. Switching sessions is a
@@ -38,6 +39,15 @@ const FIRST_FRAME_DEADLINE_MS = 4000;
38
39
  const textEnc = new TextEncoder();
39
40
  const textDec = new TextDecoder();
40
41
 
42
+ function websocketProtocols(url: string): string[] | undefined {
43
+ if (!TOKEN || !/\/api\/pty\/agents\//.test(url)) return undefined;
44
+ const bytes = textEnc.encode(TOKEN);
45
+ let raw = '';
46
+ for (let index = 0; index < bytes.length; index += 1) raw += String.fromCharCode(bytes[index]);
47
+ const encoded = btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
48
+ return ['pty-ws.v1', `cockpit.${encoded}`];
49
+ }
50
+
41
51
  // base64 → raw bytes. xterm does its own UTF-8 decoding and escape-sequence parsing, so
42
52
  // it must receive bytes (Uint8Array) — a Latin-1 string renders escapes as literal text.
43
53
  const b64ToBytes = (b64: string): Uint8Array => {
@@ -230,7 +240,7 @@ class PtyConnection {
230
240
 
231
241
  const open = () => {
232
242
  if (this.connId !== connId || this.closedByUser || this.disposed) return;
233
- const ws = new WebSocket(this.url);
243
+ const ws = new WebSocket(this.url, websocketProtocols(this.url));
234
244
  this.ws = ws;
235
245
  let gone = false;
236
246
  ws.addEventListener('open', () => {