@aiwg/cockpit 2026.7.16 → 2026.7.18

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Joe Magly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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
@@ -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
@@ -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
- import { existsSync } from 'node:fs';
12
+ import { existsSync, realpathSync } 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,
@@ -477,19 +537,27 @@ function defaultExecutorCommand() {
477
537
  return [];
478
538
  }
479
539
 
480
- async function ensureExecutor(executorUrl) {
481
- if (!AUTOSTART_EXECUTOR || await probeExecutor(executorUrl)) return;
482
- const cmd = defaultExecutorCommand();
540
+ export async function ensureExecutor(
541
+ executorUrl,
542
+ { command, probe = probeExecutor, autostart = AUTOSTART_EXECUTOR } = {},
543
+ ) {
544
+ if (!autostart || await probe(executorUrl)) return;
545
+ const cmd = command ?? defaultExecutorCommand();
483
546
  if (!cmd.length) return;
484
547
  const child = spawn(cmd[0], cmd.slice(1), {
485
548
  detached: true,
486
549
  stdio: 'ignore',
487
550
  env: { ...process.env },
488
551
  });
552
+ const started = await new Promise((resolve) => {
553
+ child.once('spawn', () => resolve(true));
554
+ child.once('error', () => resolve(false));
555
+ });
556
+ if (!started) return;
489
557
  child.unref();
490
558
  for (let i = 0; i < 30; i += 1) {
491
559
  await new Promise((resolve) => setTimeout(resolve, 500));
492
- if (await probeExecutor(executorUrl)) return;
560
+ if (await probe(executorUrl)) return;
493
561
  }
494
562
  }
495
563
 
@@ -498,6 +566,10 @@ async function proxyFirst(res, candidates, options) {
498
566
  const { status, body } = await fetchJsonFirst(candidates, options);
499
567
  return json(res, status, body);
500
568
  } catch (err) {
569
+ if ([401, 403].includes(Number(err?.upstreamStatus))) {
570
+ return json(res, Number(err.upstreamStatus), { error: err.code, message: String(err.message) });
571
+ }
572
+ if (String(err?.code ?? '').startsWith('executor_credential_')) throw err;
501
573
  const message = String(err?.message ?? err);
502
574
  const notFound = / -> 404(?:;|$)/.test(message);
503
575
  const methodNotAllowed = / -> 405(?:;|$)/.test(message);
@@ -509,7 +581,9 @@ async function proxyFirst(res, candidates, options) {
509
581
  }
510
582
 
511
583
  async function destroyInstance(upstreamUrl, instanceId) {
512
- const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
584
+ let inventory;
585
+ try { inventory = await getInventory(upstreamUrl); }
586
+ catch (err) { rethrowExecutorSecurityError(err); inventory = { instances: [] }; }
513
587
  const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
514
588
  const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
515
589
  const dockerName = inst?.launch_context?.name;
@@ -540,6 +614,7 @@ async function destroyInstance(upstreamUrl, instanceId) {
540
614
  return result;
541
615
  }
542
616
  } catch (err) {
617
+ rethrowExecutorSecurityError(err);
543
618
  const message = String(err?.message ?? err);
544
619
  // A docker/container row with a resolvable name is still physically
545
620
  // removable even when admin-v2 has no instance record (404): fall through
@@ -634,7 +709,9 @@ async function signalVmAgentReconnect(domain) {
634
709
  const VM_RUNTIME_KINDS = ['vm', 'qemu', 'kvm'];
635
710
 
636
711
  async function reconnectInstance(upstreamUrl, instanceId) {
637
- const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
712
+ let inventory;
713
+ try { inventory = await getInventory(upstreamUrl); }
714
+ catch (err) { rethrowExecutorSecurityError(err); inventory = { instances: [] }; }
638
715
  const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
639
716
  const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
640
717
  const dockerName = inst?.launch_context?.name;
@@ -646,7 +723,8 @@ async function reconnectInstance(upstreamUrl, instanceId) {
646
723
  try {
647
724
  const result = await fetchJsonFirst(candidates, { timeoutMs: 5_000 });
648
725
  if (result.status < 400) return result;
649
- } catch {
726
+ } catch (err) {
727
+ rethrowExecutorSecurityError(err);
650
728
  // agentic-sandbox v2026.7.6 still exposes the container reconnect as an
651
729
  // in-image helper, not an HTTP endpoint. Fall through to the local-dev path.
652
730
  }
@@ -766,7 +844,8 @@ async function resolveSessionAgentId(executorUrl, instanceId) {
766
844
  const agents = await getAgentList(executorUrl);
767
845
  const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(instanceId));
768
846
  return agent?.id ?? agent?.agent_id ?? agent?.agentId ?? instanceId;
769
- } catch {
847
+ } catch (err) {
848
+ rethrowExecutorSecurityError(err);
770
849
  return instanceId;
771
850
  }
772
851
  }
@@ -1001,7 +1080,8 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
1001
1080
  loadout: instance.loadout ?? runtimeExtension.loadout,
1002
1081
  image_ref: instance.image_ref ?? runtimeExtension.image_ref,
1003
1082
  };
1004
- } catch {
1083
+ } catch (err) {
1084
+ rethrowExecutorSecurityError(err);
1005
1085
  return instance;
1006
1086
  }
1007
1087
  }
@@ -1009,7 +1089,8 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
1009
1089
  async function getRegisteredAgents(executorUrl) {
1010
1090
  try {
1011
1091
  return await getAgentList(executorUrl);
1012
- } catch {
1092
+ } catch (err) {
1093
+ rethrowExecutorSecurityError(err);
1013
1094
  return [];
1014
1095
  }
1015
1096
  }
@@ -1170,7 +1251,7 @@ async function getRunning(executorUrl) {
1170
1251
  await Promise.all(
1171
1252
  instances.filter((i) => i.state === 'running').map(async (inst) => {
1172
1253
  let tasks;
1173
- try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
1254
+ try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch (err) { rethrowExecutorSecurityError(err); return; }
1174
1255
  for (const t of tasks) {
1175
1256
  const state = taskState(t);
1176
1257
  if (!ACTIVE_TASK_STATES.has(state)) continue;
@@ -1250,7 +1331,7 @@ async function getApprovals(executorUrl, status) {
1250
1331
  await Promise.all(
1251
1332
  instances.filter((i) => i.state === 'running').map(async (inst) => {
1252
1333
  let tasks;
1253
- try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
1334
+ try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch (err) { rethrowExecutorSecurityError(err); return; }
1254
1335
  for (const t of tasks) {
1255
1336
  const approval = approvalFromTask(inst, t);
1256
1337
  if (!approval) continue;
@@ -1402,7 +1483,7 @@ async function getSessionEventRows(executorUrl, instances) {
1402
1483
  const rows = [];
1403
1484
  await Promise.all((instances ?? []).map(async (inst) => {
1404
1485
  let sessions;
1405
- try { sessions = (await getSessions(executorUrl, inst.id)).sessions; } catch { return; }
1486
+ try { sessions = (await getSessions(executorUrl, inst.id)).sessions; } catch (err) { rethrowExecutorSecurityError(err); return; }
1406
1487
  for (const session of sessions) {
1407
1488
  rows.push({
1408
1489
  id: session.id,
@@ -1492,16 +1573,13 @@ async function respondApproval(executorUrl, approvalId, decision) {
1492
1573
  const { status, body } = await fetchJsonFirst(candidates);
1493
1574
  return { status, body };
1494
1575
  } catch (e) {
1576
+ rethrowExecutorSecurityError(e);
1495
1577
  return { status: 409, body: { error: 'approval_response_failed', detail: String(e?.message ?? e) } };
1496
1578
  }
1497
1579
  }
1498
1580
 
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
- */
1581
+ /** Sessions for one instance. Executor attach targets are normalized here and
1582
+ * replaced with Bridge-owned proxy URLs at the request boundary. */
1505
1583
  async function getSessions(executorUrl, instanceId) {
1506
1584
  const sessionAgentId = await resolveSessionAgentId(executorUrl, instanceId);
1507
1585
  const agentIds = unique([instanceId, sessionAgentId]);
@@ -1546,6 +1624,7 @@ async function getSessionScreen(executorUrl, instanceId, sessionId) {
1546
1624
  const { body, target, status } = await fetchJsonFirst(paths);
1547
1625
  return { status, body: normalizeScreenSnapshot(body, { instanceId, sessionId, source: target }) };
1548
1626
  } catch (e) {
1627
+ rethrowExecutorSecurityError(e);
1549
1628
  return {
1550
1629
  status: 404,
1551
1630
  body: {
@@ -1663,7 +1742,8 @@ async function endSession(executorUrl, instanceId, sessionId) {
1663
1742
  let sessions = [];
1664
1743
  try {
1665
1744
  sessions = (await getSessions(executorUrl, instanceId)).sessions;
1666
- } catch {
1745
+ } catch (err) {
1746
+ rethrowExecutorSecurityError(err);
1667
1747
  // Fall back to using the supplied id directly; older executors may not list
1668
1748
  // before delete, and delete should remain useful during recovery cleanup.
1669
1749
  }
@@ -1702,10 +1782,98 @@ function sessionResponseFromRow(row) {
1702
1782
  };
1703
1783
  }
1704
1784
 
1705
- export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = ALLOW_MOCK_EXECUTOR, token } = {}) {
1785
+ function websocketCockpitToken(req) {
1786
+ const protocols = String(req.headers['sec-websocket-protocol'] ?? '')
1787
+ .split(',')
1788
+ .map((value) => value.trim())
1789
+ .filter(Boolean);
1790
+ const encoded = protocols.find((value) => value.startsWith('cockpit.'))?.slice('cockpit.'.length) ?? '';
1791
+ try { return Buffer.from(encoded, 'base64url').toString('utf8'); } catch { return ''; }
1792
+ }
1793
+
1794
+ function websocketAuthed(req, expected) {
1795
+ const presented = websocketCockpitToken(req);
1796
+ if (presented.length !== expected.length) return false;
1797
+ try { return timingSafeEqual(Buffer.from(presented), Buffer.from(expected)); } catch { return false; }
1798
+ }
1799
+
1800
+ function writeUpgradeHead(socket, response) {
1801
+ socket.write(`HTTP/1.1 ${response.statusCode} ${response.statusMessage ?? 'Switching Protocols'}\r\n`);
1802
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
1803
+ socket.write(`${response.rawHeaders[index]}: ${response.rawHeaders[index + 1]}\r\n`);
1804
+ }
1805
+ socket.write('\r\n');
1806
+ }
1807
+
1808
+ async function proxyExecutorWebsocket({ req, socket, head, target, executorTokenFile }) {
1809
+ const token = await resolveExecutorBearer(executorTokenFile);
1810
+ const requestedProtocols = String(req.headers['sec-websocket-protocol'] ?? '')
1811
+ .split(',')
1812
+ .map((value) => value.trim())
1813
+ .filter((value) => value && !value.startsWith('cockpit.'));
1814
+ const headers = {
1815
+ connection: 'Upgrade',
1816
+ upgrade: 'websocket',
1817
+ host: target.host,
1818
+ 'sec-websocket-key': req.headers['sec-websocket-key'],
1819
+ 'sec-websocket-version': req.headers['sec-websocket-version'],
1820
+ ...(req.headers['sec-websocket-extensions'] ? { 'sec-websocket-extensions': req.headers['sec-websocket-extensions'] } : {}),
1821
+ ...(requestedProtocols.length ? { 'sec-websocket-protocol': requestedProtocols.join(', ') } : {}),
1822
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
1823
+ };
1824
+ const transport = target.protocol === 'wss:' ? https : http;
1825
+ const requestTarget = new URL(target);
1826
+ requestTarget.protocol = target.protocol === 'wss:' ? 'https:' : 'http:';
1827
+ const upstreamRequest = transport.request(requestTarget, { method: 'GET', headers });
1828
+ upstreamRequest.on('upgrade', (response, upstreamSocket, upstreamHead) => {
1829
+ writeUpgradeHead(socket, response);
1830
+ if (head.length) upstreamSocket.write(head);
1831
+ if (upstreamHead.length) socket.write(upstreamHead);
1832
+ socket.pipe(upstreamSocket);
1833
+ upstreamSocket.pipe(socket);
1834
+ const closeBoth = () => {
1835
+ if (!socket.destroyed) socket.destroy();
1836
+ if (!upstreamSocket.destroyed) upstreamSocket.destroy();
1837
+ };
1838
+ socket.on('error', closeBoth);
1839
+ upstreamSocket.on('error', closeBoth);
1840
+ });
1841
+ upstreamRequest.on('response', (response) => {
1842
+ socket.write(`HTTP/1.1 ${response.statusCode ?? 502} ${response.statusMessage ?? 'Upstream Error'}\r\nConnection: close\r\n\r\n`);
1843
+ socket.destroy();
1844
+ response.resume();
1845
+ });
1846
+ upstreamRequest.on('error', () => {
1847
+ if (!socket.destroyed) socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
1848
+ });
1849
+ upstreamRequest.end();
1850
+ }
1851
+
1852
+ export function createBridge({
1853
+ executorUrl = EXECUTOR_URL,
1854
+ allowMockExecutor = ALLOW_MOCK_EXECUTOR,
1855
+ token,
1856
+ executorTokenFile = EXECUTOR_TOKEN_FILE,
1857
+ } = {}) {
1706
1858
  const upstreamUrl = executorUrl;
1707
1859
  const TOKEN = token ?? randomBytes(24).toString('hex');
1708
- const server = http.createServer(async (req, res) => {
1860
+ const executorOrigin = new URL(upstreamUrl).origin;
1861
+ const executorAddress = new URL(upstreamUrl);
1862
+ const attachTargets = new Map();
1863
+ const issueAttachUrl = (req, value) => {
1864
+ const target = new URL(String(value));
1865
+ const sameHost = target.hostname === executorAddress.hostname ||
1866
+ (isLocalHostName(target.hostname) && isLocalHostName(executorAddress.hostname));
1867
+ if (!['ws:', 'wss:'].includes(target.protocol) || !sameHost || !/^\/agents\/[^/]+\/sessions\/[^/]+\/attach$/.test(target.pathname)) {
1868
+ throw executorAuthError('executor_attach_target_refused', 'executor returned an attach URL outside the allowed PTY endpoint');
1869
+ }
1870
+ const id = randomBytes(18).toString('base64url');
1871
+ attachTargets.set(id, target);
1872
+ if (attachTargets.size > 1024) attachTargets.delete(attachTargets.keys().next().value);
1873
+ const wsProtocol = req.socket.encrypted ? 'wss:' : 'ws:';
1874
+ return `${wsProtocol}//${req.headers.host}/api/pty${target.pathname}/${id}`;
1875
+ };
1876
+ const handleRequest = async (req, res) => {
1709
1877
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
1710
1878
  try {
1711
1879
  // unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
@@ -1818,7 +1986,12 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1818
1986
  if (url.pathname === '/api/sessions') {
1819
1987
  const inst = url.searchParams.get('instance');
1820
1988
  if (!inst) return json(res, 400, { error: 'instance_required' });
1821
- return json(res, 200, await getSessions(upstreamUrl, inst));
1989
+ const result = await getSessions(upstreamUrl, inst);
1990
+ result.sessions = result.sessions.map((session) => ({
1991
+ ...session,
1992
+ attach_url: issueAttachUrl(req, session.attach_url),
1993
+ }));
1994
+ return json(res, 200, result);
1822
1995
  }
1823
1996
  if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)$/)) && req.method === 'DELETE') {
1824
1997
  const { status, body } = await endSession(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
@@ -1992,7 +2165,13 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1992
2165
  // Same as the list path (#1671): the attach segment must be the instance
1993
2166
  // id the executor's pty-ws route accepts, not the resolved agent name.
1994
2167
  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` });
2168
+ const executorAttachUrl = attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach`;
2169
+ return json(res, status, {
2170
+ ...body,
2171
+ id: sessionId,
2172
+ session_name: body.session_name ?? body.sessionName ?? sessionName,
2173
+ attach_url: issueAttachUrl(req, executorAttachUrl),
2174
+ });
1996
2175
  }
1997
2176
 
1998
2177
  // --- management surface (UC-012): lifecycle + task cancel ---
@@ -2032,7 +2211,12 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
2032
2211
  if (url.pathname === '/api/cost' && req.method === 'GET')
2033
2212
  return proxy(res, 'GET', `${upstreamUrl}/admin/cost`);
2034
2213
 
2035
- if (url.pathname === '/api/health') return json(res, 200, { status: 'ok', executor_url: upstreamUrl, mock_executor_allowed: allowMockExecutor });
2214
+ if (url.pathname === '/api/health') return json(res, 200, {
2215
+ status: 'ok',
2216
+ executor_url: upstreamUrl,
2217
+ mock_executor_allowed: allowMockExecutor,
2218
+ executor_auth_configured: Boolean(executorTokenFile),
2219
+ });
2036
2220
  if (url.pathname === '/' || url.pathname === '/index.html') {
2037
2221
  const distIndex = join(WEB_DIST, 'index.html');
2038
2222
  const src = existsSync(distIndex) ? distIndex : join(__dir, 'public', 'index.html');
@@ -2053,9 +2237,39 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
2053
2237
  }
2054
2238
  json(res, 404, { error: 'not_found', path: url.pathname });
2055
2239
  } catch (err) {
2056
- json(res, 502, { error: 'bridge_upstream_error', message: String(err?.message ?? err) });
2240
+ const status = Number(err?.upstreamStatus) || 502;
2241
+ json(res, status, { error: err?.code ?? 'bridge_upstream_error', message: String(err?.message ?? err) });
2057
2242
  }
2058
- });
2243
+ };
2244
+ const server = http.createServer((req, res) => executorRequestContext.run(
2245
+ { executorOrigin, executorTokenFile },
2246
+ () => handleRequest(req, res),
2247
+ ));
2248
+ server.on('upgrade', (req, socket, head) => executorRequestContext.run(
2249
+ { executorOrigin, executorTokenFile },
2250
+ async () => {
2251
+ try {
2252
+ const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
2253
+ const match = url.pathname.match(/^\/api\/pty\/agents\/[^/]+\/sessions\/[^/]+\/attach\/([^/]+)$/);
2254
+ if (!match || !validBrowserOrigin(req)) {
2255
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
2256
+ return;
2257
+ }
2258
+ if (!websocketAuthed(req, TOKEN)) {
2259
+ socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
2260
+ return;
2261
+ }
2262
+ const target = attachTargets.get(match[1]);
2263
+ if (!target || url.pathname !== `/api/pty${target.pathname}/${match[1]}`) {
2264
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
2265
+ return;
2266
+ }
2267
+ await proxyExecutorWebsocket({ req, socket, head, target, executorTokenFile });
2268
+ } catch {
2269
+ if (!socket.destroyed) socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
2270
+ }
2271
+ },
2272
+ ));
2059
2273
  server.cockpitToken = TOKEN; // exposed for shells/tests
2060
2274
  return server;
2061
2275
  }
@@ -2067,6 +2281,20 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
2067
2281
  export const EXECUTOR_RESERVED_PORTS = [8120, 8121, 8122];
2068
2282
  export const DEFAULT_BRIDGE_PORT = 8140;
2069
2283
 
2284
+ /**
2285
+ * npm exposes package binaries through symlinks. Node preserves that symlink
2286
+ * in process.argv[1] while import.meta.url names the real module, so comparing
2287
+ * the two strings makes an installed `aiwg-cockpit` silently skip startup.
2288
+ */
2289
+ export function isDirectExecution(metaUrl = import.meta.url, argv1 = process.argv[1]) {
2290
+ if (!argv1) return false;
2291
+ try {
2292
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(argv1);
2293
+ } catch {
2294
+ return fileURLToPath(metaUrl) === resolve(argv1);
2295
+ }
2296
+ }
2297
+
2070
2298
  /** Resolve the Bridge listen port from the environment with a sane, off-range
2071
2299
  * default. Throws on an invalid port or a collision with the executor range. */
2072
2300
  export function resolveBridgePort(env = process.env) {
@@ -2085,7 +2313,7 @@ export function resolveBridgePort(env = process.env) {
2085
2313
  return port;
2086
2314
  }
2087
2315
 
2088
- if (import.meta.url === `file://${process.argv[1]}`) {
2316
+ if (isDirectExecution()) {
2089
2317
  const port = resolveBridgePort();
2090
2318
  await ensureExecutor(EXECUTOR_URL);
2091
2319
  const server = createBridge();