@aiwg/cockpit 2026.7.24 → 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 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 built React app (token-injected) │
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 per-launch Cockpit token; the
150
- Bridge keeps the long-lived executor credential and authenticates the upstream
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 token-injected, falling back to a legacy page when no build is present.
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 token reference or fallback token → connect) |
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) |
@@ -443,7 +444,8 @@ families in inventory. For each target it verifies inventory normalization,
443
444
  runtime and transport posture, session backend evidence, session create/list,
444
445
  observe attach, and a minimal provider-backed workload through a controller
445
446
  session when control is advertised. The selected provider is invoked through the
446
- attached session (`codex exec -s read-only ...`
447
+ attached session (`codex exec --skip-git-repo-check -s read-only ...`
448
+ for gate-owned non-repository workspaces,
447
449
  or `claude --print --permission-mode dontAsk --output-format text ...`) and must
448
450
  emit `AIWG_COCKPIT_LIVE_OK` and the expected discovery result (`issue-audit` by
449
451
  default). This proves a pre-authenticated agentic framework actually launched in
@@ -577,7 +579,9 @@ host-daemon surfacing (roctinam/aiwg#1615) and transport-trust visibility (#1618
577
579
  host-daemon now render per instance (a host-daemon *detail-status* payload
578
580
  remains a residual under #1615). Direct/managed PTY negotiation (#1616) and the
579
581
  live real-sandbox gate (#1617) continue. Secure transport details map back to agentic-sandbox#409/#410/#412; local
580
- Browser/Tauri/VS Code-to-Bridge auth remains roctinam/aiwg#1595.
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.
581
585
 
582
586
  ### Operator-wall review modes (#1622)
583
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
156
- // per-launch token injected by the Bridge for the gated control surface
157
- const TOKEN = window.__COCKPIT_TOKEN__ || '';
158
- const api = (u, o = {}) => {
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': TOKEN };
161
- return fetch(u, { ...o, headers: { ...(o.headers || {}), ...csrf, authorization: 'Bearer ' + TOKEN } });
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 && TOKEN) {
404
- const events = new EventSource('/api/events?token=' + encodeURIComponent(TOKEN));
405
- events.addEventListener('cockpit.refresh', refreshActive);
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();
@@ -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
- /** Constant-time bearer-token check (header or ?token=). */
100
- function authed(req, url, token) {
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
- const presented = bearer || url.searchParams.get('token') || '';
104
- if (presented.length !== token.length) return false;
105
- try { return timingSafeEqual(Buffer.from(presented), Buffer.from(token)); } catch { return false; }
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 ['http:', 'https:'].includes(o.protocol) &&
134
+ return o.protocol === host.protocol &&
119
135
  isLocalHostName(o.hostname) &&
120
136
  isLocalHostName(host.hostname) &&
121
- (!o.port || !host.port || o.port === host.port);
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, token) {
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
- if (csrf.length !== token.length) return false;
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). */
@@ -1409,7 +1425,11 @@ function defaultSessionLaunch(instance) {
1409
1425
  };
1410
1426
  }
1411
1427
  if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
1412
- const home = runtime === 'container' || runtime === 'docker' ? '/root' : '/home/agent';
1428
+ // Prefer the executor-reported target-local cwd. Current agentic-sandbox
1429
+ // container and VM contracts report `/home/agent`; retain that value as a
1430
+ // compatibility fallback for older inventory responses. `/root` is not
1431
+ // readable by the mandatory uid 10001 container identity.
1432
+ const home = instance?.launch_context?.cwd ?? '/home/agent';
1413
1433
  return {
1414
1434
  command: '/bin/bash',
1415
1435
  args: ['-lc', `cd ${shellSingleQuote(home)} && exec /bin/bash -l`],
@@ -2244,9 +2264,46 @@ export function createBridge({
2244
2264
  token,
2245
2265
  executorTokenFile = EXECUTOR_TOKEN_FILE,
2246
2266
  requireSandboxMtls = REQUIRE_SANDBOX_MTLS,
2267
+ bootstrapTtlMs = 60_000,
2268
+ sessionTtlMs = 12 * 60 * 60 * 1000,
2247
2269
  } = {}) {
2248
2270
  const upstreamUrl = executorUrl;
2249
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);
2250
2307
  const executorOrigin = new URL(upstreamUrl).origin;
2251
2308
  const executorAddress = new URL(upstreamUrl);
2252
2309
  const attachTargets = new Map();
@@ -2268,14 +2325,62 @@ export function createBridge({
2268
2325
  try {
2269
2326
  // unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
2270
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
+ }
2271
2374
  if (url.pathname.startsWith('/api/') && !validBrowserOrigin(req)) {
2272
2375
  return json(res, 403, { error: 'forbidden_origin' });
2273
2376
  }
2274
- // gate the control surface: per-launch bearer token on every /api/ call
2275
- if (url.pathname.startsWith('/api/') && !authed(req, url, TOKEN)) {
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) {
2276
2381
  return json(res, 401, { error: 'unauthorized', detail: 'missing or invalid cockpit token' });
2277
2382
  }
2278
- if (url.pathname.startsWith('/api/') && !validCsrf(req, TOKEN)) {
2383
+ if (url.pathname.startsWith('/api/') && !validCsrf(req, auth)) {
2279
2384
  return json(res, 403, { error: 'csrf_required' });
2280
2385
  }
2281
2386
  if (url.pathname.startsWith('/api/')) {
@@ -2642,13 +2747,15 @@ export function createBridge({
2642
2747
  const distIndex = join(WEB_DIST, 'index.html');
2643
2748
  const src = existsSync(distIndex) ? distIndex : join(__dir, 'public', 'index.html');
2644
2749
  const raw = await readFile(src, 'utf8');
2645
- // Inject the per-launch token so the same-origin app can call the gated API.
2646
- const html = raw.replace('</head>', `<script>window.__COCKPIT_TOKEN__=${JSON.stringify(TOKEN)}</script>\n</head>`);
2647
- // never cache the shell — it must always reference the latest hashed bundle
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.
2648
2754
  res.writeHead(200, {
2649
2755
  'content-type': 'text/html; charset=utf-8',
2650
- 'cache-control': 'no-cache',
2651
- 'set-cookie': `cockpit_csrf=${TOKEN}; Path=/; SameSite=Strict`,
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',
2652
2759
  });
2653
2760
  return res.end(html);
2654
2761
  }
@@ -2676,7 +2783,7 @@ export function createBridge({
2676
2783
  socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
2677
2784
  return;
2678
2785
  }
2679
- if (!websocketAuthed(req, TOKEN)) {
2786
+ if (!websocketAuthed(req, TOKEN) && !sessionAuth(req)) {
2680
2787
  socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
2681
2788
  return;
2682
2789
  }
@@ -2692,6 +2799,7 @@ export function createBridge({
2692
2799
  },
2693
2800
  ));
2694
2801
  server.cockpitToken = TOKEN; // exposed for shells/tests
2802
+ server.issueBootstrapNonce = issueBootstrapNonce;
2695
2803
  return server;
2696
2804
  }
2697
2805
 
@@ -2741,8 +2849,10 @@ if (isDirectExecution()) {
2741
2849
  server.listen(port, '127.0.0.1', async () => {
2742
2850
  try {
2743
2851
  const file = await writeRuntimeToken({ token: server.cockpitToken, port, pid: process.pid });
2852
+ const browserNonce = server.issueBootstrapNonce('browser');
2744
2853
  console.log(`[cockpit-bridge] http://127.0.0.1:${port} (executor ${EXECUTOR_URL})`);
2745
- console.log(` token written ${file} (mode 600) — open the URL in a browser or attach a shell`);
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)`);
2746
2856
  } catch (err) {
2747
2857
  console.error(`[cockpit-bridge] failed to persist runtime token: ${String(err?.message ?? err)}`);
2748
2858
  server.close(() => process.exit(1));
@@ -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=wrong`)).status, 401, 'gate: bad token -> 401');
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 with the per-launch token injected (React build if present, else
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(`window.__COCKPIT_TOKEN__=${JSON.stringify(bridge.cockpitToken)}`), 'token injected into the served app');
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`) and opens a window
7
- at the Bridge UI with the resolved per-launch token on the query string.
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/?token=…
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's per-launch runtime token file and opens a window at the
6
- // Bridge UI (token on the query string).
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::{fs, path::PathBuf, thread, time::Duration};
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 open the window at the Bridge UI with the token.
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 url = format!("http://127.0.0.1:{port}/?token={token}");
40
- if let Ok(parsed) = url.parse() {
41
- let _ = WebviewWindowBuilder::new(&handle, "main", WebviewUrl::External(parsed))
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 http://127.0.0.1:* http://localhost:*; style-src 'unsafe-inline'"
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.7.24",
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",
@@ -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 at `http://127.0.0.1:<port>/?token=<token>`
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` gates every `/api/*` call (constant-time bearer check); `tenant_id` elsewhere
39
- is a **routing** token, never authentication.
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
 
@@ -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, and loads the Bridge UI at
5
- // <url>/?token=<token>. Control plane is the gated Bridge API; data plane (pty) is
6
- // the executor URL the Bridge issues. This module is the one source of that contract.
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
- /** The webview URL a shell loads — Bridge UI with the token on the query string. */
44
- export function webviewUrl(rt) {
45
- return `${rt.url}/?token=${encodeURIComponent(rt.token)}`;
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. */
@@ -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
- assert.match(webviewUrl(rt), /\/\?token=/, 'webview url carries the token');
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 (reads the Bridge runtime handshake, resolves the token, loads `http://127.0.0.1:PORT/?token=…`). |
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`).