@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 +21 -0
- package/README.md +37 -3
- package/bridge/src/server.mjs +260 -32
- package/bridge/src/smoke.mjs +15 -4
- package/package.json +4 -1
- package/web/dist/assets/index-B0ea5aQ1.css +32 -0
- package/web/dist/assets/index-BOViwdKc.js +312 -0
- package/web/dist/index.html +14 -0
- 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/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
|
-
│
|
|
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
|
|
@@ -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
|
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
|
-
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
|
|
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,
|
|
@@ -477,19 +537,27 @@ function defaultExecutorCommand() {
|
|
|
477
537
|
return [];
|
|
478
538
|
}
|
|
479
539
|
|
|
480
|
-
async function ensureExecutor(
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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 (
|
|
2316
|
+
if (isDirectExecution()) {
|
|
2089
2317
|
const port = resolveBridgePort();
|
|
2090
2318
|
await ensureExecutor(EXECUTOR_URL);
|
|
2091
2319
|
const server = createBridge();
|