@aiwg/cockpit 2026.7.25 → 2026.8.1

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) |
@@ -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 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.
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) => ({ '&': '&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). */
@@ -1860,8 +1876,12 @@ async function taskMissionSession(executorUrl) {
1860
1876
 
1861
1877
  async function getMissions(executorUrl) {
1862
1878
  const sessions = await readMcSessions();
1863
- const live = await taskMissionSession(executorUrl);
1879
+ const [live, fleetSessions] = await Promise.all([
1880
+ taskMissionSession(executorUrl),
1881
+ fleetMissionSessions(executorUrl),
1882
+ ]);
1864
1883
  if (live) sessions.unshift(live);
1884
+ sessions.unshift(...fleetSessions);
1865
1885
  const missions = sessions.flatMap((s) => s.missions);
1866
1886
  return {
1867
1887
  source: 'aiwg-mc + agentic-sandbox',
@@ -1872,6 +1892,104 @@ async function getMissions(executorUrl) {
1872
1892
  };
1873
1893
  }
1874
1894
 
1895
+ const FLEET_TERMINAL_STATES = new Set(['succeeded', 'failed', 'cancelled', 'timed-out']);
1896
+
1897
+ function fleetParentState(records) {
1898
+ const states = records.map((record) => record.status?.observed_state ?? 'unknown');
1899
+ if (states.some((state) => state === 'operator-review-required' || state === 'unknown')) return 'operator-review-required';
1900
+ if (records.some((record) => record.status?.backpressure?.reason === 'approval')) return 'awaiting-approval';
1901
+ if (states.some((state) => state === 'failed' || state === 'timed-out')) return 'failed';
1902
+ if (states.length > 0 && states.every((state) => FLEET_TERMINAL_STATES.has(state))) return 'completed';
1903
+ return 'active';
1904
+ }
1905
+
1906
+ function fleetMissionProjection(record, sessionId) {
1907
+ const lineage = record.lineage ?? {};
1908
+ const status = record.status ?? {};
1909
+ const artifacts = Array.isArray(status.artifacts) ? status.artifacts : [];
1910
+ return {
1911
+ id: lineage.child_id,
1912
+ session_id: sessionId,
1913
+ source: 'agentic-sandbox-fleet',
1914
+ title: `${record.kind ?? 'workload'} ${lineage.child_id ?? 'unknown'}`,
1915
+ status: status.observed_state ?? 'unknown',
1916
+ terminal: FLEET_TERMINAL_STATES.has(status.observed_state),
1917
+ parent_mission_id: lineage.mission_id,
1918
+ workload_kind: record.kind,
1919
+ desired_state: record.spec?.desired_state,
1920
+ target_id: lineage.target_id,
1921
+ executor_id: lineage.executor_id,
1922
+ runtime_id: lineage.runtime_id,
1923
+ instance_id: lineage.runtime_id,
1924
+ runtime_session_id: lineage.session_id,
1925
+ task_id: lineage.task_id,
1926
+ command_id: lineage.command_id,
1927
+ dispatch_id: lineage.dispatch_id,
1928
+ revision: status.revision,
1929
+ last_seen: status.last_seen,
1930
+ health: status.health,
1931
+ backpressure: status.backpressure,
1932
+ artifacts,
1933
+ exit_classification: status.exit_classification,
1934
+ error: status.error_code,
1935
+ schedule: record.spec?.schedule,
1936
+ };
1937
+ }
1938
+
1939
+ async function fleetMissionSessions(executorUrl) {
1940
+ let response;
1941
+ try {
1942
+ response = await fetchJsonFirst([`${executorUrl}/api/v2/fleet/workloads`]);
1943
+ } catch (err) {
1944
+ rethrowExecutorSecurityError(err);
1945
+ if (/\s->\s(?:404|405)(?:;|$)/.test(String(err?.message ?? err))) return [];
1946
+ throw err;
1947
+ }
1948
+ if (response.status === 404 || response.status === 405) return [];
1949
+ if (response.status < 200 || response.status >= 300) {
1950
+ throw new Error(`Agentic Sandbox fleet inventory failed with HTTP ${response.status}`);
1951
+ }
1952
+ const snapshot = response.body?.inventory ?? response.body;
1953
+ if (
1954
+ snapshot?.document_type !== 'inventory'
1955
+ || snapshot?.api_version !== 'agentic-orchestration/v1'
1956
+ || !Array.isArray(snapshot?.records)
1957
+ ) {
1958
+ throw new Error('Agentic Sandbox returned an invalid fleet inventory envelope');
1959
+ }
1960
+ const records = snapshot.records;
1961
+ const groups = new Map();
1962
+ const childIds = new Set();
1963
+ for (const record of records) {
1964
+ const missionId = record?.lineage?.mission_id;
1965
+ const childId = record?.lineage?.child_id;
1966
+ if (!missionId || !childId || !record?.kind || !record?.status?.observed_state) {
1967
+ throw new Error('Agentic Sandbox fleet inventory contains an invalid workload record');
1968
+ }
1969
+ if (childIds.has(childId)) throw new Error(`Agentic Sandbox fleet inventory repeats child '${childId}'`);
1970
+ childIds.add(childId);
1971
+ const group = groups.get(missionId) ?? [];
1972
+ group.push(record);
1973
+ groups.set(missionId, group);
1974
+ }
1975
+ return [...groups.entries()].map(([missionId, missionRecords]) => {
1976
+ const sessionId = `fleet:${missionId}`;
1977
+ const lastSeen = missionRecords.map((record) => record.status?.last_seen).filter(Boolean).sort().at(-1);
1978
+ return {
1979
+ id: sessionId,
1980
+ parent_mission_id: missionId,
1981
+ name: `Fleet mission ${missionId}`,
1982
+ state: fleetParentState(missionRecords),
1983
+ source: 'agentic-sandbox-fleet',
1984
+ updated_at: lastSeen ?? snapshot.generated_at,
1985
+ inventory_revision: snapshot.inventory_revision,
1986
+ audit_count: 0,
1987
+ audit_tail: [],
1988
+ missions: missionRecords.map((record) => fleetMissionProjection(record, sessionId)),
1989
+ };
1990
+ });
1991
+ }
1992
+
1875
1993
  async function getSessionEventRows(executorUrl, instances) {
1876
1994
  const rows = [];
1877
1995
  await Promise.all((instances ?? []).map(async (inst) => {
@@ -2248,9 +2366,46 @@ export function createBridge({
2248
2366
  token,
2249
2367
  executorTokenFile = EXECUTOR_TOKEN_FILE,
2250
2368
  requireSandboxMtls = REQUIRE_SANDBOX_MTLS,
2369
+ bootstrapTtlMs = 60_000,
2370
+ sessionTtlMs = 12 * 60 * 60 * 1000,
2251
2371
  } = {}) {
2252
2372
  const upstreamUrl = executorUrl;
2253
2373
  const TOKEN = token ?? randomBytes(24).toString('hex');
2374
+ const bootstrapNonces = new Map();
2375
+ const browserSessions = new Map();
2376
+ const digest = (value) => createHash('sha256').update(String(value)).digest('base64url');
2377
+ const issueBootstrapNonce = (audience = 'browser') => {
2378
+ if (!['browser', 'tauri', 'vscode'].includes(audience)) {
2379
+ throw executorAuthError('invalid_bootstrap_audience', 'bootstrap audience must be browser, tauri, or vscode');
2380
+ }
2381
+ const nonce = randomBytes(24).toString('base64url');
2382
+ bootstrapNonces.set(digest(nonce), { audience, expiresAt: Date.now() + bootstrapTtlMs });
2383
+ return nonce;
2384
+ };
2385
+ const consumeBootstrapNonce = (nonce, audience) => {
2386
+ const key = digest(nonce);
2387
+ const pending = bootstrapNonces.get(key);
2388
+ bootstrapNonces.delete(key);
2389
+ return Boolean(
2390
+ pending &&
2391
+ pending.expiresAt >= Date.now() &&
2392
+ pending.audience === audience &&
2393
+ ['browser', 'tauri', 'vscode'].includes(audience),
2394
+ );
2395
+ };
2396
+ const sessionAuth = (req) => {
2397
+ const id = cookies(req).cockpit_session ?? '';
2398
+ const session = browserSessions.get(digest(id));
2399
+ if (!session) return null;
2400
+ if (session.expiresAt < Date.now()) {
2401
+ browserSessions.delete(digest(id));
2402
+ return null;
2403
+ }
2404
+ return { kind: 'session', csrf: session.csrf };
2405
+ };
2406
+ const requestAuth = (req) => bearerAuthed(req, TOKEN)
2407
+ ? { kind: 'bearer', csrf: TOKEN }
2408
+ : sessionAuth(req);
2254
2409
  const executorOrigin = new URL(upstreamUrl).origin;
2255
2410
  const executorAddress = new URL(upstreamUrl);
2256
2411
  const attachTargets = new Map();
@@ -2272,14 +2427,62 @@ export function createBridge({
2272
2427
  try {
2273
2428
  // unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
2274
2429
  if (url.pathname === '/healthz') return json(res, 200, { status: 'ok' });
2430
+ if (url.pathname === '/bootstrap/nonce' && req.method === 'POST') {
2431
+ if (!validBrowserOrigin(req) || !bearerAuthed(req, TOKEN)) {
2432
+ return json(res, 401, { error: 'unauthorized' });
2433
+ }
2434
+ const parsed = await readJsonBody(req);
2435
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2436
+ try {
2437
+ const payload = JSON.stringify({
2438
+ nonce: issueBootstrapNonce(String(parsed.body.audience ?? 'browser')),
2439
+ expires_in_ms: bootstrapTtlMs,
2440
+ });
2441
+ res.writeHead(201, {
2442
+ 'content-type': 'application/json',
2443
+ 'cache-control': 'no-store',
2444
+ 'content-length': Buffer.byteLength(payload),
2445
+ });
2446
+ return res.end(payload);
2447
+ } catch (err) {
2448
+ return json(res, 400, { error: err.code ?? 'invalid_bootstrap_audience' });
2449
+ }
2450
+ }
2451
+ if (url.pathname === '/bootstrap/session' && req.method === 'POST') {
2452
+ if (!validBrowserOrigin(req)) return json(res, 403, { error: 'forbidden_origin' });
2453
+ const parsed = await readJsonBody(req);
2454
+ if (parsed.error) return json(res, 400, { error: parsed.error });
2455
+ const nonce = String(parsed.body.nonce ?? '');
2456
+ const audience = String(parsed.body.audience ?? '');
2457
+ if (!nonce || !consumeBootstrapNonce(nonce, audience)) {
2458
+ return json(res, 401, { error: 'bootstrap_invalid_or_expired' });
2459
+ }
2460
+ const id = randomBytes(32).toString('base64url');
2461
+ const csrf = randomBytes(24).toString('base64url');
2462
+ browserSessions.set(digest(id), { csrf, audience, expiresAt: Date.now() + sessionTtlMs });
2463
+ res.writeHead(201, {
2464
+ 'content-type': 'application/json',
2465
+ 'cache-control': 'no-store',
2466
+ 'set-cookie': `cockpit_session=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.ceil(sessionTtlMs / 1000)}`,
2467
+ });
2468
+ return res.end(JSON.stringify({ csrf, expires_in_ms: sessionTtlMs }));
2469
+ }
2470
+ if (url.pathname === '/bootstrap/session' && req.method === 'GET') {
2471
+ const auth = sessionAuth(req);
2472
+ if (!auth) return json(res, 401, { error: 'unauthorized' });
2473
+ res.setHeader('cache-control', 'no-store');
2474
+ return json(res, 200, { csrf: auth.csrf });
2475
+ }
2275
2476
  if (url.pathname.startsWith('/api/') && !validBrowserOrigin(req)) {
2276
2477
  return json(res, 403, { error: 'forbidden_origin' });
2277
2478
  }
2278
- // gate the control surface: per-launch bearer token on every /api/ call
2279
- if (url.pathname.startsWith('/api/') && !authed(req, url, TOKEN)) {
2479
+ // Gate the control surface with either an explicit bearer for non-browser
2480
+ // clients or the HttpOnly session established by a one-time bootstrap.
2481
+ const auth = url.pathname.startsWith('/api/') ? requestAuth(req) : null;
2482
+ if (url.pathname.startsWith('/api/') && !auth) {
2280
2483
  return json(res, 401, { error: 'unauthorized', detail: 'missing or invalid cockpit token' });
2281
2484
  }
2282
- if (url.pathname.startsWith('/api/') && !validCsrf(req, TOKEN)) {
2485
+ if (url.pathname.startsWith('/api/') && !validCsrf(req, auth)) {
2283
2486
  return json(res, 403, { error: 'csrf_required' });
2284
2487
  }
2285
2488
  if (url.pathname.startsWith('/api/')) {
@@ -2646,13 +2849,15 @@ export function createBridge({
2646
2849
  const distIndex = join(WEB_DIST, 'index.html');
2647
2850
  const src = existsSync(distIndex) ? distIndex : join(__dir, 'public', 'index.html');
2648
2851
  const raw = await readFile(src, 'utf8');
2649
- // Inject the per-launch token so the same-origin app can call the gated API.
2650
- const html = raw.replace('</head>', `<script>window.__COCKPIT_TOKEN__=${JSON.stringify(TOKEN)}</script>\n</head>`);
2651
- // never cache the shell — it must always reference the latest hashed bundle
2852
+ // The app exchanges a one-time nonce from the URL fragment for an
2853
+ // HttpOnly session. No reusable credential is injected into HTML.
2854
+ const html = raw;
2855
+ // Never cache the shell or bootstrap-bearing navigation.
2652
2856
  res.writeHead(200, {
2653
2857
  'content-type': 'text/html; charset=utf-8',
2654
- 'cache-control': 'no-cache',
2655
- 'set-cookie': `cockpit_csrf=${TOKEN}; Path=/; SameSite=Strict`,
2858
+ 'cache-control': 'no-store',
2859
+ '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:`,
2860
+ 'referrer-policy': 'no-referrer',
2656
2861
  });
2657
2862
  return res.end(html);
2658
2863
  }
@@ -2680,7 +2885,7 @@ export function createBridge({
2680
2885
  socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
2681
2886
  return;
2682
2887
  }
2683
- if (!websocketAuthed(req, TOKEN)) {
2888
+ if (!websocketAuthed(req, TOKEN) && !sessionAuth(req)) {
2684
2889
  socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
2685
2890
  return;
2686
2891
  }
@@ -2696,6 +2901,7 @@ export function createBridge({
2696
2901
  },
2697
2902
  ));
2698
2903
  server.cockpitToken = TOKEN; // exposed for shells/tests
2904
+ server.issueBootstrapNonce = issueBootstrapNonce;
2699
2905
  return server;
2700
2906
  }
2701
2907
 
@@ -2745,8 +2951,10 @@ if (isDirectExecution()) {
2745
2951
  server.listen(port, '127.0.0.1', async () => {
2746
2952
  try {
2747
2953
  const file = await writeRuntimeToken({ token: server.cockpitToken, port, pid: process.pid });
2954
+ const browserNonce = server.issueBootstrapNonce('browser');
2748
2955
  console.log(`[cockpit-bridge] http://127.0.0.1:${port} (executor ${EXECUTOR_URL})`);
2749
- console.log(` token written ${file} (mode 600) — open the URL in a browser or attach a shell`);
2956
+ console.log(` runtime handshake ${file} (mode 600)`);
2957
+ console.log(` browser bootstrap http://127.0.0.1:${port}/#bootstrap=${browserNonce}&audience=browser (one-time, 60s)`);
2750
2958
  } catch (err) {
2751
2959
  console.error(`[cockpit-bridge] failed to persist runtime token: ${String(err?.message ?? err)}`);
2752
2960
  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.25",
3
+ "version": "2026.8.1",
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