@aiwg/cockpit 2026.7.25 → 2026.8.0
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 +10 -7
- package/bridge/src/public/index.html +32 -8
- package/bridge/src/server.mjs +128 -22
- package/bridge/src/smoke.mjs +4 -4
- package/desktop/README.md +4 -3
- package/desktop/src-tauri/src/main.rs +45 -8
- package/desktop/src-tauri/tauri.conf.json +1 -1
- package/package.json +1 -1
- package/runtime-docs/README.md +7 -3
- package/shell-core/runtime.mjs +16 -6
- package/shell-core/smoke.mjs +4 -1
- package/vscode/README.md +4 -1
- package/vscode/extension.js +6 -4
- package/vscode/smoke.mjs +16 -2
- package/web/dist/assets/{index-BTBL-9AY.js → index-CX4YsqA4.js} +102 -102
- package/web/dist/index.html +1 -2
- package/web/index.html +0 -1
- package/web/package.json +1 -1
- package/web/src/App.test.tsx +0 -1
- package/web/src/App.tsx +23 -11
- package/web/src/api.test.ts +42 -0
- package/web/src/api.ts +51 -11
- package/web/src/components/Inventory.test.tsx +1 -3
- package/web/src/components/LaunchInstanceModal.test.tsx +1 -1
- package/web/src/components/Sessions.test.tsx +0 -1
- package/web/src/components/StartSessionModal.test.tsx +1 -1
- package/web/src/useSession.ts +1 -7
- package/web/src/vite-env.d.ts +1 -0
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ operator / CLI: aiwg cockpit
|
|
|
135
135
|
│ cost, sessions (create + attach_url), contributions │
|
|
136
136
|
│ · read-only catalog: aiwg discover / show (display only) │
|
|
137
137
|
│ · user asset library: clone/import/delete (never writes AIWG)│
|
|
138
|
-
│ · serves the
|
|
138
|
+
│ · serves the React app (one-time nonce → HttpOnly session) │
|
|
139
139
|
└─────────────────────────────────────────────────────────────┘
|
|
140
140
|
│ authenticated proxy ▲ loads the token-gated shell
|
|
141
141
|
▼ │
|
|
@@ -146,9 +146,9 @@ operator / CLI: aiwg cockpit
|
|
|
146
146
|
|
|
147
147
|
- **Control plane** (lifecycle, approvals, actions) goes through the gated Bridge.
|
|
148
148
|
- **Data plane** (the PTY session stream) also goes through a Bridge-owned
|
|
149
|
-
`attach_url`. The browser presents only its
|
|
150
|
-
Bridge keeps the
|
|
151
|
-
WebSocket upgrade.
|
|
149
|
+
`attach_url`. The browser presents only its HttpOnly Cockpit session; the
|
|
150
|
+
Bridge keeps the native-shell and executor credentials and authenticates the
|
|
151
|
+
upstream WebSocket upgrade.
|
|
152
152
|
|
|
153
153
|
## Surfaces (tabs)
|
|
154
154
|
|
|
@@ -343,7 +343,8 @@ Reconnect action never creates a replacement instance and never destroys the
|
|
|
343
343
|
running container; it only attempts to restore the missing agent registration.
|
|
344
344
|
|
|
345
345
|
`aiwg cockpit` (the operator command) will wrap this; the Bridge serves the built
|
|
346
|
-
React app
|
|
346
|
+
React app through the same one-time bootstrap/session contract, falling back to
|
|
347
|
+
a legacy page when no build is present.
|
|
347
348
|
|
|
348
349
|
## Components
|
|
349
350
|
|
|
@@ -352,7 +353,7 @@ React app token-injected, falling back to a legacy page when no build is present
|
|
|
352
353
|
| `web/` | React 19 + Vite + TS UI (the surfaces above) |
|
|
353
354
|
| `mock-executor/` | **automated-test-only** wire-faithful agentic-sandbox A2A v2 stand-in (conformance 33/0/17). The Bridge refuses it for human launches (needs `AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR=1`); a contract guard (#1636) pins its legacy `/admin/{running,approvals,cost}` divergence from real v2 so new drift fails CI. |
|
|
354
355
|
| `bridge/` | the registry-bound control-plane server + static serving |
|
|
355
|
-
| `shell-core/` | the cross-shell handshake (runtime
|
|
356
|
+
| `shell-core/` | the cross-shell handshake (native runtime credential → one-time browser bootstrap) |
|
|
356
357
|
| `vscode/` · `desktop/` | VS Code extension + Tauri shells over the same Bridge |
|
|
357
358
|
| `contrib/` | declarative UI contributions + schema (actions inject commands) |
|
|
358
359
|
| `poc/` | Iteration-1 risk-gate PoCs (kill-bridge isolation, security) |
|
|
@@ -578,7 +579,9 @@ host-daemon surfacing (roctinam/aiwg#1615) and transport-trust visibility (#1618
|
|
|
578
579
|
host-daemon now render per instance (a host-daemon *detail-status* payload
|
|
579
580
|
remains a residual under #1615). Direct/managed PTY negotiation (#1616) and the
|
|
580
581
|
live real-sandbox gate (#1617) continue. Secure transport details map back to agentic-sandbox#409/#410/#412; local
|
|
581
|
-
Browser/Tauri/VS Code-to-Bridge auth
|
|
582
|
+
Browser/Tauri/VS Code-to-Bridge auth is implemented under #1595/#1968 with
|
|
583
|
+
one-time audience-bound bootstrap, HttpOnly session custody, and credential-free
|
|
584
|
+
REST/SSE/PTY URLs.
|
|
582
585
|
|
|
583
586
|
### Operator-wall review modes (#1622)
|
|
584
587
|
|
|
@@ -153,12 +153,32 @@
|
|
|
153
153
|
const meta = document.getElementById('meta');
|
|
154
154
|
const fmt = (id) => (id && id.length > 12) ? id.slice(0, 8) + '…' : id;
|
|
155
155
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
// Exchange a one-time URL-fragment nonce for an HttpOnly session. The
|
|
157
|
+
// fragment is removed before any control-surface request is made.
|
|
158
|
+
let CSRF = '';
|
|
159
|
+
const sessionReady = (async () => {
|
|
160
|
+
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
161
|
+
const nonce = params.get('bootstrap') || '';
|
|
162
|
+
const next = params.get('next') || '';
|
|
163
|
+
const response = nonce
|
|
164
|
+
? await fetch('/bootstrap/session', {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
credentials: 'same-origin',
|
|
167
|
+
headers: { 'content-type': 'application/json' },
|
|
168
|
+
body: JSON.stringify({ nonce, audience: params.get('audience') || 'browser' }),
|
|
169
|
+
})
|
|
170
|
+
: await fetch('/bootstrap/session', { credentials: 'same-origin' });
|
|
171
|
+
if (!response.ok) throw new Error(`Cockpit session bootstrap failed (${response.status})`);
|
|
172
|
+
const body = await response.json();
|
|
173
|
+
if (!body.csrf) throw new Error('Cockpit session bootstrap returned no CSRF binding');
|
|
174
|
+
CSRF = body.csrf;
|
|
175
|
+
if (nonce) history.replaceState(null, '', location.pathname + location.search + (next ? '#' + next : ''));
|
|
176
|
+
})();
|
|
177
|
+
const api = async (u, o = {}) => {
|
|
178
|
+
await sessionReady;
|
|
159
179
|
const method = String(o.method || 'GET').toUpperCase();
|
|
160
|
-
const csrf = ['GET', 'HEAD', 'OPTIONS'].includes(method) ? {} : { 'x-cockpit-csrf':
|
|
161
|
-
return fetch(u, { ...o, headers: { ...(o.headers || {}), ...csrf
|
|
180
|
+
const csrf = ['GET', 'HEAD', 'OPTIONS'].includes(method) ? {} : { 'x-cockpit-csrf': CSRF };
|
|
181
|
+
return fetch(u, { ...o, credentials: 'same-origin', headers: { ...(o.headers || {}), ...csrf } });
|
|
162
182
|
};
|
|
163
183
|
|
|
164
184
|
// --- Inventory ---
|
|
@@ -400,9 +420,13 @@
|
|
|
400
420
|
else discover();
|
|
401
421
|
}
|
|
402
422
|
document.getElementById('refresh').addEventListener('click', refreshActive);
|
|
403
|
-
if ('EventSource' in window
|
|
404
|
-
|
|
405
|
-
|
|
423
|
+
if ('EventSource' in window) {
|
|
424
|
+
sessionReady.then(() => {
|
|
425
|
+
const events = new EventSource('/api/events');
|
|
426
|
+
events.addEventListener('cockpit.refresh', refreshActive);
|
|
427
|
+
}).catch((error) => {
|
|
428
|
+
meta.textContent = error.message;
|
|
429
|
+
});
|
|
406
430
|
}
|
|
407
431
|
|
|
408
432
|
loadInventory();
|
package/bridge/src/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import https from 'node:https';
|
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
|
|
12
12
|
import { existsSync, realpathSync } from 'node:fs';
|
|
13
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
14
14
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -96,13 +96,29 @@ async function serveDistFile(res, relPath) {
|
|
|
96
96
|
// First-party contribution manifests; AIWG-extension-sourced ones layer in via AIWG_COCKPIT_CONTRIB (#1591).
|
|
97
97
|
const CONTRIB_DIRS = [fileURLToPath(new URL('../../contrib', import.meta.url)), ...(process.env.AIWG_COCKPIT_CONTRIB ? [process.env.AIWG_COCKPIT_CONTRIB] : [])];
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
function constantTimeEqual(presented, expected) {
|
|
100
|
+
if (presented.length !== expected.length) return false;
|
|
101
|
+
try { return timingSafeEqual(Buffer.from(presented), Buffer.from(expected)); } catch { return false; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Constant-time bearer-token check. URL query credentials are never accepted. */
|
|
105
|
+
function bearerAuthed(req, token) {
|
|
101
106
|
const hdr = String(req.headers['authorization'] ?? '');
|
|
102
107
|
const bearer = hdr.startsWith('Bearer ') ? hdr.slice(7) : '';
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
return constantTimeEqual(bearer, token);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cookies(req) {
|
|
112
|
+
return Object.fromEntries(String(req.headers.cookie ?? '')
|
|
113
|
+
.split(';')
|
|
114
|
+
.map((part) => part.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((part) => {
|
|
117
|
+
const split = part.indexOf('=');
|
|
118
|
+
if (split < 0) return [part, ''];
|
|
119
|
+
try { return [part.slice(0, split), decodeURIComponent(part.slice(split + 1))]; }
|
|
120
|
+
catch { return [part.slice(0, split), '']; }
|
|
121
|
+
}));
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
function isLocalHostName(hostname) {
|
|
@@ -115,21 +131,21 @@ function validBrowserOrigin(req) {
|
|
|
115
131
|
try {
|
|
116
132
|
const o = new URL(String(origin));
|
|
117
133
|
const host = new URL(`http://${req.headers.host ?? 'localhost'}`);
|
|
118
|
-
return
|
|
134
|
+
return o.protocol === host.protocol &&
|
|
119
135
|
isLocalHostName(o.hostname) &&
|
|
120
136
|
isLocalHostName(host.hostname) &&
|
|
121
|
-
|
|
137
|
+
o.hostname === host.hostname &&
|
|
138
|
+
o.port === host.port;
|
|
122
139
|
} catch {
|
|
123
140
|
return false;
|
|
124
141
|
}
|
|
125
142
|
}
|
|
126
143
|
|
|
127
|
-
function validCsrf(req,
|
|
144
|
+
function validCsrf(req, auth) {
|
|
128
145
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method ?? 'GET')) return true;
|
|
129
|
-
if (!req.headers.origin) return true;
|
|
146
|
+
if (auth?.kind === 'bearer' && !req.headers.origin) return true;
|
|
130
147
|
const csrf = String(req.headers['x-cockpit-csrf'] ?? '');
|
|
131
|
-
|
|
132
|
-
try { return timingSafeEqual(Buffer.from(csrf), Buffer.from(token)); } catch { return false; }
|
|
148
|
+
return constantTimeEqual(csrf, auth?.csrf ?? '');
|
|
133
149
|
}
|
|
134
150
|
|
|
135
151
|
/** Persist the per-launch token for the desktop/VS Code shells to read (mode 600). */
|
|
@@ -2248,9 +2264,46 @@ export function createBridge({
|
|
|
2248
2264
|
token,
|
|
2249
2265
|
executorTokenFile = EXECUTOR_TOKEN_FILE,
|
|
2250
2266
|
requireSandboxMtls = REQUIRE_SANDBOX_MTLS,
|
|
2267
|
+
bootstrapTtlMs = 60_000,
|
|
2268
|
+
sessionTtlMs = 12 * 60 * 60 * 1000,
|
|
2251
2269
|
} = {}) {
|
|
2252
2270
|
const upstreamUrl = executorUrl;
|
|
2253
2271
|
const TOKEN = token ?? randomBytes(24).toString('hex');
|
|
2272
|
+
const bootstrapNonces = new Map();
|
|
2273
|
+
const browserSessions = new Map();
|
|
2274
|
+
const digest = (value) => createHash('sha256').update(String(value)).digest('base64url');
|
|
2275
|
+
const issueBootstrapNonce = (audience = 'browser') => {
|
|
2276
|
+
if (!['browser', 'tauri', 'vscode'].includes(audience)) {
|
|
2277
|
+
throw executorAuthError('invalid_bootstrap_audience', 'bootstrap audience must be browser, tauri, or vscode');
|
|
2278
|
+
}
|
|
2279
|
+
const nonce = randomBytes(24).toString('base64url');
|
|
2280
|
+
bootstrapNonces.set(digest(nonce), { audience, expiresAt: Date.now() + bootstrapTtlMs });
|
|
2281
|
+
return nonce;
|
|
2282
|
+
};
|
|
2283
|
+
const consumeBootstrapNonce = (nonce, audience) => {
|
|
2284
|
+
const key = digest(nonce);
|
|
2285
|
+
const pending = bootstrapNonces.get(key);
|
|
2286
|
+
bootstrapNonces.delete(key);
|
|
2287
|
+
return Boolean(
|
|
2288
|
+
pending &&
|
|
2289
|
+
pending.expiresAt >= Date.now() &&
|
|
2290
|
+
pending.audience === audience &&
|
|
2291
|
+
['browser', 'tauri', 'vscode'].includes(audience),
|
|
2292
|
+
);
|
|
2293
|
+
};
|
|
2294
|
+
const sessionAuth = (req) => {
|
|
2295
|
+
const id = cookies(req).cockpit_session ?? '';
|
|
2296
|
+
const session = browserSessions.get(digest(id));
|
|
2297
|
+
if (!session) return null;
|
|
2298
|
+
if (session.expiresAt < Date.now()) {
|
|
2299
|
+
browserSessions.delete(digest(id));
|
|
2300
|
+
return null;
|
|
2301
|
+
}
|
|
2302
|
+
return { kind: 'session', csrf: session.csrf };
|
|
2303
|
+
};
|
|
2304
|
+
const requestAuth = (req) => bearerAuthed(req, TOKEN)
|
|
2305
|
+
? { kind: 'bearer', csrf: TOKEN }
|
|
2306
|
+
: sessionAuth(req);
|
|
2254
2307
|
const executorOrigin = new URL(upstreamUrl).origin;
|
|
2255
2308
|
const executorAddress = new URL(upstreamUrl);
|
|
2256
2309
|
const attachTargets = new Map();
|
|
@@ -2272,14 +2325,62 @@ export function createBridge({
|
|
|
2272
2325
|
try {
|
|
2273
2326
|
// unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
|
|
2274
2327
|
if (url.pathname === '/healthz') return json(res, 200, { status: 'ok' });
|
|
2328
|
+
if (url.pathname === '/bootstrap/nonce' && req.method === 'POST') {
|
|
2329
|
+
if (!validBrowserOrigin(req) || !bearerAuthed(req, TOKEN)) {
|
|
2330
|
+
return json(res, 401, { error: 'unauthorized' });
|
|
2331
|
+
}
|
|
2332
|
+
const parsed = await readJsonBody(req);
|
|
2333
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
2334
|
+
try {
|
|
2335
|
+
const payload = JSON.stringify({
|
|
2336
|
+
nonce: issueBootstrapNonce(String(parsed.body.audience ?? 'browser')),
|
|
2337
|
+
expires_in_ms: bootstrapTtlMs,
|
|
2338
|
+
});
|
|
2339
|
+
res.writeHead(201, {
|
|
2340
|
+
'content-type': 'application/json',
|
|
2341
|
+
'cache-control': 'no-store',
|
|
2342
|
+
'content-length': Buffer.byteLength(payload),
|
|
2343
|
+
});
|
|
2344
|
+
return res.end(payload);
|
|
2345
|
+
} catch (err) {
|
|
2346
|
+
return json(res, 400, { error: err.code ?? 'invalid_bootstrap_audience' });
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
if (url.pathname === '/bootstrap/session' && req.method === 'POST') {
|
|
2350
|
+
if (!validBrowserOrigin(req)) return json(res, 403, { error: 'forbidden_origin' });
|
|
2351
|
+
const parsed = await readJsonBody(req);
|
|
2352
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
2353
|
+
const nonce = String(parsed.body.nonce ?? '');
|
|
2354
|
+
const audience = String(parsed.body.audience ?? '');
|
|
2355
|
+
if (!nonce || !consumeBootstrapNonce(nonce, audience)) {
|
|
2356
|
+
return json(res, 401, { error: 'bootstrap_invalid_or_expired' });
|
|
2357
|
+
}
|
|
2358
|
+
const id = randomBytes(32).toString('base64url');
|
|
2359
|
+
const csrf = randomBytes(24).toString('base64url');
|
|
2360
|
+
browserSessions.set(digest(id), { csrf, audience, expiresAt: Date.now() + sessionTtlMs });
|
|
2361
|
+
res.writeHead(201, {
|
|
2362
|
+
'content-type': 'application/json',
|
|
2363
|
+
'cache-control': 'no-store',
|
|
2364
|
+
'set-cookie': `cockpit_session=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.ceil(sessionTtlMs / 1000)}`,
|
|
2365
|
+
});
|
|
2366
|
+
return res.end(JSON.stringify({ csrf, expires_in_ms: sessionTtlMs }));
|
|
2367
|
+
}
|
|
2368
|
+
if (url.pathname === '/bootstrap/session' && req.method === 'GET') {
|
|
2369
|
+
const auth = sessionAuth(req);
|
|
2370
|
+
if (!auth) return json(res, 401, { error: 'unauthorized' });
|
|
2371
|
+
res.setHeader('cache-control', 'no-store');
|
|
2372
|
+
return json(res, 200, { csrf: auth.csrf });
|
|
2373
|
+
}
|
|
2275
2374
|
if (url.pathname.startsWith('/api/') && !validBrowserOrigin(req)) {
|
|
2276
2375
|
return json(res, 403, { error: 'forbidden_origin' });
|
|
2277
2376
|
}
|
|
2278
|
-
//
|
|
2279
|
-
|
|
2377
|
+
// Gate the control surface with either an explicit bearer for non-browser
|
|
2378
|
+
// clients or the HttpOnly session established by a one-time bootstrap.
|
|
2379
|
+
const auth = url.pathname.startsWith('/api/') ? requestAuth(req) : null;
|
|
2380
|
+
if (url.pathname.startsWith('/api/') && !auth) {
|
|
2280
2381
|
return json(res, 401, { error: 'unauthorized', detail: 'missing or invalid cockpit token' });
|
|
2281
2382
|
}
|
|
2282
|
-
if (url.pathname.startsWith('/api/') && !validCsrf(req,
|
|
2383
|
+
if (url.pathname.startsWith('/api/') && !validCsrf(req, auth)) {
|
|
2283
2384
|
return json(res, 403, { error: 'csrf_required' });
|
|
2284
2385
|
}
|
|
2285
2386
|
if (url.pathname.startsWith('/api/')) {
|
|
@@ -2646,13 +2747,15 @@ export function createBridge({
|
|
|
2646
2747
|
const distIndex = join(WEB_DIST, 'index.html');
|
|
2647
2748
|
const src = existsSync(distIndex) ? distIndex : join(__dir, 'public', 'index.html');
|
|
2648
2749
|
const raw = await readFile(src, 'utf8');
|
|
2649
|
-
//
|
|
2650
|
-
|
|
2651
|
-
|
|
2750
|
+
// The app exchanges a one-time nonce from the URL fragment for an
|
|
2751
|
+
// HttpOnly session. No reusable credential is injected into HTML.
|
|
2752
|
+
const html = raw;
|
|
2753
|
+
// Never cache the shell or bootstrap-bearing navigation.
|
|
2652
2754
|
res.writeHead(200, {
|
|
2653
2755
|
'content-type': 'text/html; charset=utf-8',
|
|
2654
|
-
'cache-control': 'no-
|
|
2655
|
-
'
|
|
2756
|
+
'cache-control': 'no-store',
|
|
2757
|
+
'content-security-policy': `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://${req.headers.host} wss://${req.headers.host}; frame-ancestors 'self' vscode-webview: tauri:`,
|
|
2758
|
+
'referrer-policy': 'no-referrer',
|
|
2656
2759
|
});
|
|
2657
2760
|
return res.end(html);
|
|
2658
2761
|
}
|
|
@@ -2680,7 +2783,7 @@ export function createBridge({
|
|
|
2680
2783
|
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
2681
2784
|
return;
|
|
2682
2785
|
}
|
|
2683
|
-
if (!websocketAuthed(req, TOKEN)) {
|
|
2786
|
+
if (!websocketAuthed(req, TOKEN) && !sessionAuth(req)) {
|
|
2684
2787
|
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
2685
2788
|
return;
|
|
2686
2789
|
}
|
|
@@ -2696,6 +2799,7 @@ export function createBridge({
|
|
|
2696
2799
|
},
|
|
2697
2800
|
));
|
|
2698
2801
|
server.cockpitToken = TOKEN; // exposed for shells/tests
|
|
2802
|
+
server.issueBootstrapNonce = issueBootstrapNonce;
|
|
2699
2803
|
return server;
|
|
2700
2804
|
}
|
|
2701
2805
|
|
|
@@ -2745,8 +2849,10 @@ if (isDirectExecution()) {
|
|
|
2745
2849
|
server.listen(port, '127.0.0.1', async () => {
|
|
2746
2850
|
try {
|
|
2747
2851
|
const file = await writeRuntimeToken({ token: server.cockpitToken, port, pid: process.pid });
|
|
2852
|
+
const browserNonce = server.issueBootstrapNonce('browser');
|
|
2748
2853
|
console.log(`[cockpit-bridge] http://127.0.0.1:${port} (executor ${EXECUTOR_URL})`);
|
|
2749
|
-
console.log(`
|
|
2854
|
+
console.log(` runtime handshake ${file} (mode 600)`);
|
|
2855
|
+
console.log(` browser bootstrap http://127.0.0.1:${port}/#bootstrap=${browserNonce}&audience=browser (one-time, 60s)`);
|
|
2750
2856
|
} catch (err) {
|
|
2751
2857
|
console.error(`[cockpit-bridge] failed to persist runtime token: ${String(err?.message ?? err)}`);
|
|
2752
2858
|
server.close(() => process.exit(1));
|
package/bridge/src/smoke.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const f = (p, o = {}) => fetch(base + p, { ...o, headers: { ...(o.headers || {})
|
|
|
19
19
|
try {
|
|
20
20
|
// auth gate: /api/ without the token is 401; /healthz is open
|
|
21
21
|
assert.equal((await fetch(`${base}/api/inventory`)).status, 401, 'gate: no token -> 401');
|
|
22
|
-
assert.equal((await fetch(`${base}/api/inventory?token
|
|
22
|
+
assert.equal((await fetch(`${base}/api/inventory?token=${encodeURIComponent(bridge.cockpitToken)}`)).status, 401, 'gate: URL token is never accepted');
|
|
23
23
|
assert.equal((await fetch(`${base}/healthz`)).status, 200, 'healthz open (no token)');
|
|
24
24
|
|
|
25
25
|
// data path: Bridge reads the executor admin inventory
|
|
@@ -222,11 +222,11 @@ try {
|
|
|
222
222
|
assert.match(started.id ?? '', /^sess-/, 'start-session returns a new session id');
|
|
223
223
|
assert.match(started.attach_url ?? '', /\/sessions\/sess-[^/]+\/attach\/[A-Za-z0-9_-]+$/, 'start-session issues a proxied ws attach_url');
|
|
224
224
|
|
|
225
|
-
// app shell served
|
|
226
|
-
// the legacy fallback — both carry the title + token)
|
|
225
|
+
// app shell is served without reusable credential material.
|
|
227
226
|
const html = await (await fetch(base + "/")).text();
|
|
228
227
|
assert.match(html, /AIWG.?Cockpit/i, 'app title rendered');
|
|
229
|
-
assert.ok(html.includes(
|
|
228
|
+
assert.ok(!html.includes(bridge.cockpitToken), 'root does not inject the control token');
|
|
229
|
+
assert.ok(!html.includes('__COCKPIT_TOKEN__'), 'root has no legacy token bootstrap global');
|
|
230
230
|
// strip HTML comments BEFORE matching — a module script trapped inside a comment
|
|
231
231
|
// (the Vite '</head>'-in-comment gotcha) must not count as "referenced".
|
|
232
232
|
const live = html.replace(/<!--[\s\S]*?-->/g, '');
|
package/desktop/README.md
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
A lightweight native window hosting the **same registry-bound Bridge UI** as the
|
|
4
4
|
VS Code shell and the browser. The shell does not replace the CLI or reimplement
|
|
5
5
|
the control plane — `src-tauri/src/main.rs` waits for the Bridge's per-launch
|
|
6
|
-
runtime handshake file (`~/.aiwg/cockpit/runtime/bridge.json`)
|
|
7
|
-
|
|
6
|
+
runtime handshake file (`~/.aiwg/cockpit/runtime/bridge.json`), exchanges the
|
|
7
|
+
native credential for a one-time nonce, and opens the Bridge UI without a
|
|
8
|
+
reusable credential in the URL.
|
|
8
9
|
|
|
9
10
|
## Architecture
|
|
10
11
|
|
|
@@ -14,7 +15,7 @@ operator/CLI: aiwg cockpit
|
|
|
14
15
|
▼
|
|
15
16
|
Bridge (127.0.0.1:PORT, token-gated /api) ── proxies ──▶ agentic-sandbox executor
|
|
16
17
|
▲
|
|
17
|
-
│ loads http://127.0.0.1:PORT
|
|
18
|
+
│ loads http://127.0.0.1:PORT/#bootstrap=…
|
|
18
19
|
desktop window (this app) ◀── same UI ──▶ VS Code webview / browser
|
|
19
20
|
```
|
|
20
21
|
|
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The desktop window hosts the SAME registry-bound Bridge UI as the VS Code shell
|
|
4
4
|
// and the browser. It does not replace the CLI or reimplement the control plane:
|
|
5
|
-
// it waits for the Bridge
|
|
6
|
-
//
|
|
5
|
+
// it waits for the Bridge runtime handshake, exchanges the native credential for
|
|
6
|
+
// a one-time nonce, and opens a window without placing the credential in a URL.
|
|
7
7
|
//
|
|
8
8
|
// Build is toolchain-gated: requires the Rust toolchain + Tauri prerequisites
|
|
9
9
|
// (on Linux, webkit2gtk + libsoup). Run `cargo tauri init` once to generate icons
|
|
10
10
|
// and capabilities, then `cargo tauri build`. See README.md.
|
|
11
|
-
use std::{
|
|
11
|
+
use std::{
|
|
12
|
+
fs,
|
|
13
|
+
io::{Read, Write},
|
|
14
|
+
net::TcpStream,
|
|
15
|
+
path::PathBuf,
|
|
16
|
+
thread,
|
|
17
|
+
time::Duration,
|
|
18
|
+
};
|
|
12
19
|
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
|
13
20
|
|
|
14
21
|
fn runtime_file() -> PathBuf {
|
|
@@ -27,23 +34,53 @@ fn read_runtime() -> Option<(u16, String)> {
|
|
|
27
34
|
Some((port, token))
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
fn issue_bootstrap_nonce(port: u16, token: &str) -> Option<String> {
|
|
38
|
+
let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?;
|
|
39
|
+
stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
|
|
40
|
+
let body = r#"{"audience":"tauri"}"#;
|
|
41
|
+
let request = format!(
|
|
42
|
+
"POST /bootstrap/nonce HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
43
|
+
body.len()
|
|
44
|
+
);
|
|
45
|
+
stream.write_all(request.as_bytes()).ok()?;
|
|
46
|
+
let mut response = String::new();
|
|
47
|
+
stream.read_to_string(&mut response).ok()?;
|
|
48
|
+
let (head, raw_body) = response.split_once("\r\n\r\n")?;
|
|
49
|
+
if !head.starts_with("HTTP/1.1 201 ") {
|
|
50
|
+
return None;
|
|
51
|
+
}
|
|
52
|
+
serde_json::from_str::<serde_json::Value>(raw_body)
|
|
53
|
+
.ok()?
|
|
54
|
+
.get("nonce")?
|
|
55
|
+
.as_str()
|
|
56
|
+
.map(ToOwned::to_owned)
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
fn main() {
|
|
31
60
|
tauri::Builder::default()
|
|
32
61
|
.setup(|app| {
|
|
33
62
|
let handle = app.handle().clone();
|
|
34
63
|
// Poll for the Bridge runtime file (operator/CLI launches `aiwg cockpit`),
|
|
35
|
-
// then
|
|
64
|
+
// then exchange the native token for a one-time webview bootstrap.
|
|
36
65
|
thread::spawn(move || {
|
|
37
66
|
for _ in 0..100 {
|
|
38
67
|
if let Some((port, token)) = read_runtime() {
|
|
39
|
-
let
|
|
40
|
-
|
|
41
|
-
|
|
68
|
+
if let Some(nonce) = issue_bootstrap_nonce(port, &token) {
|
|
69
|
+
let url = format!(
|
|
70
|
+
"http://127.0.0.1:{port}/#bootstrap={nonce}&audience=tauri"
|
|
71
|
+
);
|
|
72
|
+
if let Ok(parsed) = url.parse() {
|
|
73
|
+
let _ = WebviewWindowBuilder::new(
|
|
74
|
+
&handle,
|
|
75
|
+
"main",
|
|
76
|
+
WebviewUrl::External(parsed),
|
|
77
|
+
)
|
|
42
78
|
.title("AIWG Cockpit")
|
|
43
79
|
.inner_size(1100.0, 760.0)
|
|
44
80
|
.build();
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
45
83
|
}
|
|
46
|
-
return;
|
|
47
84
|
}
|
|
48
85
|
thread::sleep(Duration::from_millis(150));
|
|
49
86
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"app": {
|
|
10
10
|
"withGlobalTauri": false,
|
|
11
11
|
"security": {
|
|
12
|
-
"csp": "default-src 'none'; frame-src
|
|
12
|
+
"csp": "default-src 'none'; frame-src 'self'; style-src 'unsafe-inline'"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"bundle": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwg/cockpit",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.8.0",
|
|
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/runtime-docs/README.md
CHANGED
|
@@ -21,7 +21,9 @@ Every shell (browser, VS Code, Tauri) resolves the Bridge the same way — see
|
|
|
21
21
|
1. read `bridge.json` → `{ token_ref, port }` or fallback `{ token, port }`
|
|
22
22
|
2. resolve `token_ref` through `apps/cockpit/shell-core/keychain.mjs` when present
|
|
23
23
|
3. wait for `http://127.0.0.1:<port>/healthz`
|
|
24
|
-
4. load the UI
|
|
24
|
+
4. authenticate `POST /bootstrap/nonce` natively and load the UI with only the
|
|
25
|
+
returned one-time nonce in the URL fragment
|
|
26
|
+
5. exchange the nonce for an HttpOnly `SameSite=Strict` browser session
|
|
25
27
|
|
|
26
28
|
## Security
|
|
27
29
|
|
|
@@ -35,8 +37,10 @@ Every shell (browser, VS Code, Tauri) resolves the Bridge the same way — see
|
|
|
35
37
|
`AIWG_COCKPIT_KEYCHAIN_STRICT=1` to omit the inline token when keychain storage
|
|
36
38
|
succeeds; set `AIWG_COCKPIT_REQUIRE_KEYCHAIN=1` to fail Bridge launch if no OS
|
|
37
39
|
credential backend is usable.
|
|
38
|
-
- `token`
|
|
39
|
-
|
|
40
|
+
- The native `token` can authenticate non-browser `/api/*` calls and issue
|
|
41
|
+
one-time browser bootstraps. Browser REST, SSE, and PTY traffic use an
|
|
42
|
+
HttpOnly session; query-token authentication is not accepted. `tenant_id`
|
|
43
|
+
elsewhere is a **routing** token, never authentication.
|
|
40
44
|
- Browser-origin `/api/*` calls are localhost-origin checked, and state-changing
|
|
41
45
|
browser calls must include the CSRF double-submit header emitted by the web clients.
|
|
42
46
|
|
package/shell-core/runtime.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Shell-core: the handshake every Cockpit shell (VS Code, Tauri, browser) shares.
|
|
2
2
|
// The Bridge writes ~/.aiwg/cockpit/runtime/bridge.json (mode 600) on launch with
|
|
3
3
|
// { token_ref, port } when OS-keychain storage is available, else { token, port }.
|
|
4
|
-
// A shell resolves the token, waits for liveness,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// A shell resolves the token, waits for liveness, then asks the Bridge for a
|
|
5
|
+
// one-time bootstrap nonce. The reusable token stays in the native shell and
|
|
6
|
+
// never enters the webview URL. This module is the one source of that contract.
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
@@ -40,9 +40,19 @@ export async function connect({ timeoutMs = 5000, file = RUNTIME_FILE } = {}) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
/** Issue a short-lived, one-time browser bootstrap and place only that nonce in
|
|
44
|
+
* the URL fragment (fragments are not sent in HTTP requests or referrers). */
|
|
45
|
+
export async function webviewUrl(rt, { audience = 'browser', next = '' } = {}) {
|
|
46
|
+
const response = await api(rt, '/bootstrap/nonce', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
body: JSON.stringify({ audience }),
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) throw new Error(`Bridge bootstrap refused (${response.status})`);
|
|
52
|
+
const { nonce } = await response.json();
|
|
53
|
+
if (!nonce) throw new Error('Bridge bootstrap returned no nonce');
|
|
54
|
+
const fragment = new URLSearchParams({ bootstrap: nonce, audience, ...(next ? { next } : {}) });
|
|
55
|
+
return `${rt.url}/#${fragment}`;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
/** Authed fetch against the Bridge control surface, for shells that call the API directly. */
|
package/shell-core/smoke.mjs
CHANGED
|
@@ -29,7 +29,10 @@ try {
|
|
|
29
29
|
const rt = await connect({ timeoutMs: 6000 }); // reads ~/.aiwg/cockpit/runtime/bridge.json + waits for /healthz
|
|
30
30
|
assert.equal(rt.port, PORT, 'runtime port matches the launched Bridge');
|
|
31
31
|
assert.ok(rt.token && rt.token.length >= 32, 'runtime carries a per-launch token');
|
|
32
|
-
|
|
32
|
+
const launchUrl = await webviewUrl(rt, { audience: 'browser' });
|
|
33
|
+
assert.match(launchUrl, /\/#bootstrap=/, 'webview URL carries a one-time fragment bootstrap');
|
|
34
|
+
assert.ok(!launchUrl.includes(rt.token), 'webview URL does not carry the reusable token');
|
|
35
|
+
assert.ok(!launchUrl.includes('?token='), 'webview URL has no token query parameter');
|
|
33
36
|
|
|
34
37
|
// the shell handshake: authed call succeeds, unauthed is gated
|
|
35
38
|
assert.equal((await api(rt, '/api/health')).status, 200, 'authed /api/health 200');
|
package/vscode/README.md
CHANGED
|
@@ -8,7 +8,7 @@ contributed actions as command-palette entries. No build step (CommonJS
|
|
|
8
8
|
|
|
9
9
|
| Command | Effect |
|
|
10
10
|
|---|---|
|
|
11
|
-
| **AIWG Cockpit: Open** | Opens the Cockpit UI in a webview
|
|
11
|
+
| **AIWG Cockpit: Open** | Opens the Cockpit UI in a webview after exchanging the native runtime credential for a one-time fragment bootstrap. |
|
|
12
12
|
| **AIWG Cockpit: Audit Issues** | Opens Cockpit on the contributed Actions view; the action injects into an agentic session instead of running from the extension. |
|
|
13
13
|
|
|
14
14
|
## Run it
|
|
@@ -20,6 +20,9 @@ contributed actions as command-palette entries. No build step (CommonJS
|
|
|
20
20
|
If the Bridge isn't running, the commands show a hint to start it — the shell
|
|
21
21
|
never replaces the CLI; it fronts it.
|
|
22
22
|
|
|
23
|
+
The wrapper CSP permits only the exact resolved Bridge origin and port. It
|
|
24
|
+
does not allow arbitrary `localhost:*` or `127.0.0.1:*` frames.
|
|
25
|
+
|
|
23
26
|
## Settings
|
|
24
27
|
|
|
25
28
|
- `aiwg-cockpit.bridgeRuntimeFile` — override the runtime file path (default `~/.aiwg/cockpit/runtime/bridge.json`).
|
package/vscode/extension.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// AIWG Cockpit — VS Code shell (#1594).
|
|
2
2
|
// Hosts the registry-bound Bridge UI inside a webview, and exposes contributed
|
|
3
3
|
// actions as command-palette entries. The shell never replaces the CLI or the
|
|
4
|
-
// Bridge — it
|
|
4
|
+
// Bridge — it resolves the native runtime credential, exchanges it for a
|
|
5
|
+
// one-time webview bootstrap, and never places the credential in a URL.
|
|
5
6
|
// CommonJS so it runs with no build step.
|
|
6
7
|
const vscode = require('vscode');
|
|
7
8
|
const os = require('os');
|
|
@@ -35,9 +36,10 @@ function activate(context) {
|
|
|
35
36
|
try { rt = await ensureRuntime(); } catch (e) { return vscode.window.showWarningMessage(e.message); }
|
|
36
37
|
const panel = vscode.window.createWebviewPanel('aiwgCockpit', 'AIWG Cockpit', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true });
|
|
37
38
|
const { webviewUrl } = await shellCore();
|
|
38
|
-
const u = webviewUrl(rt);
|
|
39
|
+
const u = await webviewUrl(rt, { audience: 'vscode' });
|
|
40
|
+
const bridgeOrigin = new URL(rt.url).origin;
|
|
39
41
|
panel.webview.html = `<!doctype html><html><head><meta charset="utf-8" />
|
|
40
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; frame-src
|
|
42
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; frame-src ${bridgeOrigin}; style-src 'unsafe-inline';" />
|
|
41
43
|
<style>html,body,iframe{margin:0;height:100vh;width:100%;border:0}</style></head>
|
|
42
44
|
<body><iframe src="${u}" title="AIWG Cockpit"></iframe></body></html>`;
|
|
43
45
|
}),
|
|
@@ -45,7 +47,7 @@ function activate(context) {
|
|
|
45
47
|
let rt;
|
|
46
48
|
try { rt = await ensureRuntime(); } catch (e) { return vscode.window.showWarningMessage(e.message); }
|
|
47
49
|
const { webviewUrl } = await shellCore();
|
|
48
|
-
vscode.env.openExternal(vscode.Uri.parse(
|
|
50
|
+
vscode.env.openExternal(vscode.Uri.parse(await webviewUrl(rt, { audience: 'browser', next: 'actions' })));
|
|
49
51
|
}),
|
|
50
52
|
);
|
|
51
53
|
}
|