@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 +12 -8
- package/bridge/src/public/index.html +32 -8
- package/bridge/src/server.mjs +133 -23
- 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-BoRrjczI.js → index-CX4YsqA4.js} +101 -101
- 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) |
|
|
@@ -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
|
|
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) => ({ '&': '&', '<': '<', '>': '>' }[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). */
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
2275
|
-
|
|
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,
|
|
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
|
-
//
|
|
2646
|
-
|
|
2647
|
-
|
|
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-
|
|
2651
|
-
'
|
|
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(`
|
|
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));
|
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`).
|