@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 +44 -10
- package/bridge/src/server.mjs +232 -26
- package/bridge/src/smoke.mjs +15 -4
- package/package.json +1 -1
- package/web/src/App.test.tsx +44 -1
- package/web/src/App.tsx +55 -9
- package/web/src/components/Sessions.tsx +9 -2
- package/web/src/useSession.ts +13 -3
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
|
-
│
|
|
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
|
|
133
|
-
`attach_url
|
|
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`,
|
package/bridge/src/server.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
}
|
package/bridge/src/smoke.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/web/src/App.test.tsx
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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 Cockpit</h1>
|
|
164
205
|
</div>
|
|
165
|
-
<div className="top-status" aria-label="Cockpit status">
|
|
166
|
-
<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
|
|
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;
|
package/web/src/useSession.ts
CHANGED
|
@@ -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.
|
|
8
|
-
//
|
|
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', () => {
|