@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 +10 -7
- package/bridge/src/public/index.html +32 -8
- package/bridge/src/server.mjs +231 -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-CQkRFleq.css +32 -0
- package/web/dist/assets/{index-BTBL-9AY.js → index-DXOAWGVj.js} +101 -101
- package/web/dist/index.html +2 -3
- package/web/index.html +0 -1
- package/web/package.json +1 -1
- package/web/src/App.test.tsx +41 -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/Missions.tsx +61 -10
- package/web/src/components/Sessions.test.tsx +0 -1
- package/web/src/components/StartSessionModal.test.tsx +1 -1
- package/web/src/styles.css +5 -0
- package/web/src/types.ts +18 -0
- package/web/src/useSession.ts +1 -7
- package/web/src/vite-env.d.ts +1 -0
- package/web/dist/assets/index-CesGeDud.css +0 -32
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ operator / CLI: aiwg cockpit
|
|
|
135
135
|
│ cost, sessions (create + attach_url), contributions │
|
|
136
136
|
│ · read-only catalog: aiwg discover / show (display only) │
|
|
137
137
|
│ · user asset library: clone/import/delete (never writes AIWG)│
|
|
138
|
-
│ · serves the
|
|
138
|
+
│ · serves the React app (one-time nonce → HttpOnly session) │
|
|
139
139
|
└─────────────────────────────────────────────────────────────┘
|
|
140
140
|
│ authenticated proxy ▲ loads the token-gated shell
|
|
141
141
|
▼ │
|
|
@@ -146,9 +146,9 @@ operator / CLI: aiwg cockpit
|
|
|
146
146
|
|
|
147
147
|
- **Control plane** (lifecycle, approvals, actions) goes through the gated Bridge.
|
|
148
148
|
- **Data plane** (the PTY session stream) also goes through a Bridge-owned
|
|
149
|
-
`attach_url`. The browser presents only its
|
|
150
|
-
Bridge keeps the
|
|
151
|
-
WebSocket upgrade.
|
|
149
|
+
`attach_url`. The browser presents only its HttpOnly Cockpit session; the
|
|
150
|
+
Bridge keeps the native-shell and executor credentials and authenticates the
|
|
151
|
+
upstream WebSocket upgrade.
|
|
152
152
|
|
|
153
153
|
## Surfaces (tabs)
|
|
154
154
|
|
|
@@ -343,7 +343,8 @@ Reconnect action never creates a replacement instance and never destroys the
|
|
|
343
343
|
running container; it only attempts to restore the missing agent registration.
|
|
344
344
|
|
|
345
345
|
`aiwg cockpit` (the operator command) will wrap this; the Bridge serves the built
|
|
346
|
-
React app
|
|
346
|
+
React app through the same one-time bootstrap/session contract, falling back to
|
|
347
|
+
a legacy page when no build is present.
|
|
347
348
|
|
|
348
349
|
## Components
|
|
349
350
|
|
|
@@ -352,7 +353,7 @@ React app token-injected, falling back to a legacy page when no build is present
|
|
|
352
353
|
| `web/` | React 19 + Vite + TS UI (the surfaces above) |
|
|
353
354
|
| `mock-executor/` | **automated-test-only** wire-faithful agentic-sandbox A2A v2 stand-in (conformance 33/0/17). The Bridge refuses it for human launches (needs `AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR=1`); a contract guard (#1636) pins its legacy `/admin/{running,approvals,cost}` divergence from real v2 so new drift fails CI. |
|
|
354
355
|
| `bridge/` | the registry-bound control-plane server + static serving |
|
|
355
|
-
| `shell-core/` | the cross-shell handshake (runtime
|
|
356
|
+
| `shell-core/` | the cross-shell handshake (native runtime credential → one-time browser bootstrap) |
|
|
356
357
|
| `vscode/` · `desktop/` | VS Code extension + Tauri shells over the same Bridge |
|
|
357
358
|
| `contrib/` | declarative UI contributions + schema (actions inject commands) |
|
|
358
359
|
| `poc/` | Iteration-1 risk-gate PoCs (kill-bridge isolation, security) |
|
|
@@ -578,7 +579,9 @@ host-daemon surfacing (roctinam/aiwg#1615) and transport-trust visibility (#1618
|
|
|
578
579
|
host-daemon now render per instance (a host-daemon *detail-status* payload
|
|
579
580
|
remains a residual under #1615). Direct/managed PTY negotiation (#1616) and the
|
|
580
581
|
live real-sandbox gate (#1617) continue. Secure transport details map back to agentic-sandbox#409/#410/#412; local
|
|
581
|
-
Browser/Tauri/VS Code-to-Bridge auth
|
|
582
|
+
Browser/Tauri/VS Code-to-Bridge auth is implemented under #1595/#1968 with
|
|
583
|
+
one-time audience-bound bootstrap, HttpOnly session custody, and credential-free
|
|
584
|
+
REST/SSE/PTY URLs.
|
|
582
585
|
|
|
583
586
|
### Operator-wall review modes (#1622)
|
|
584
587
|
|
|
@@ -153,12 +153,32 @@
|
|
|
153
153
|
const meta = document.getElementById('meta');
|
|
154
154
|
const fmt = (id) => (id && id.length > 12) ? id.slice(0, 8) + '…' : id;
|
|
155
155
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
// Exchange a one-time URL-fragment nonce for an HttpOnly session. The
|
|
157
|
+
// fragment is removed before any control-surface request is made.
|
|
158
|
+
let CSRF = '';
|
|
159
|
+
const sessionReady = (async () => {
|
|
160
|
+
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
161
|
+
const nonce = params.get('bootstrap') || '';
|
|
162
|
+
const next = params.get('next') || '';
|
|
163
|
+
const response = nonce
|
|
164
|
+
? await fetch('/bootstrap/session', {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
credentials: 'same-origin',
|
|
167
|
+
headers: { 'content-type': 'application/json' },
|
|
168
|
+
body: JSON.stringify({ nonce, audience: params.get('audience') || 'browser' }),
|
|
169
|
+
})
|
|
170
|
+
: await fetch('/bootstrap/session', { credentials: 'same-origin' });
|
|
171
|
+
if (!response.ok) throw new Error(`Cockpit session bootstrap failed (${response.status})`);
|
|
172
|
+
const body = await response.json();
|
|
173
|
+
if (!body.csrf) throw new Error('Cockpit session bootstrap returned no CSRF binding');
|
|
174
|
+
CSRF = body.csrf;
|
|
175
|
+
if (nonce) history.replaceState(null, '', location.pathname + location.search + (next ? '#' + next : ''));
|
|
176
|
+
})();
|
|
177
|
+
const api = async (u, o = {}) => {
|
|
178
|
+
await sessionReady;
|
|
159
179
|
const method = String(o.method || 'GET').toUpperCase();
|
|
160
|
-
const csrf = ['GET', 'HEAD', 'OPTIONS'].includes(method) ? {} : { 'x-cockpit-csrf':
|
|
161
|
-
return fetch(u, { ...o, headers: { ...(o.headers || {}), ...csrf
|
|
180
|
+
const csrf = ['GET', 'HEAD', 'OPTIONS'].includes(method) ? {} : { 'x-cockpit-csrf': CSRF };
|
|
181
|
+
return fetch(u, { ...o, credentials: 'same-origin', headers: { ...(o.headers || {}), ...csrf } });
|
|
162
182
|
};
|
|
163
183
|
|
|
164
184
|
// --- Inventory ---
|
|
@@ -400,9 +420,13 @@
|
|
|
400
420
|
else discover();
|
|
401
421
|
}
|
|
402
422
|
document.getElementById('refresh').addEventListener('click', refreshActive);
|
|
403
|
-
if ('EventSource' in window
|
|
404
|
-
|
|
405
|
-
|
|
423
|
+
if ('EventSource' in window) {
|
|
424
|
+
sessionReady.then(() => {
|
|
425
|
+
const events = new EventSource('/api/events');
|
|
426
|
+
events.addEventListener('cockpit.refresh', refreshActive);
|
|
427
|
+
}).catch((error) => {
|
|
428
|
+
meta.textContent = error.message;
|
|
429
|
+
});
|
|
406
430
|
}
|
|
407
431
|
|
|
408
432
|
loadInventory();
|
package/bridge/src/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import https from 'node:https';
|
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
|
|
12
12
|
import { existsSync, realpathSync } from 'node:fs';
|
|
13
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
14
14
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -96,13 +96,29 @@ async function serveDistFile(res, relPath) {
|
|
|
96
96
|
// First-party contribution manifests; AIWG-extension-sourced ones layer in via AIWG_COCKPIT_CONTRIB (#1591).
|
|
97
97
|
const CONTRIB_DIRS = [fileURLToPath(new URL('../../contrib', import.meta.url)), ...(process.env.AIWG_COCKPIT_CONTRIB ? [process.env.AIWG_COCKPIT_CONTRIB] : [])];
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
function constantTimeEqual(presented, expected) {
|
|
100
|
+
if (presented.length !== expected.length) return false;
|
|
101
|
+
try { return timingSafeEqual(Buffer.from(presented), Buffer.from(expected)); } catch { return false; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Constant-time bearer-token check. URL query credentials are never accepted. */
|
|
105
|
+
function bearerAuthed(req, token) {
|
|
101
106
|
const hdr = String(req.headers['authorization'] ?? '');
|
|
102
107
|
const bearer = hdr.startsWith('Bearer ') ? hdr.slice(7) : '';
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
return constantTimeEqual(bearer, token);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cookies(req) {
|
|
112
|
+
return Object.fromEntries(String(req.headers.cookie ?? '')
|
|
113
|
+
.split(';')
|
|
114
|
+
.map((part) => part.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((part) => {
|
|
117
|
+
const split = part.indexOf('=');
|
|
118
|
+
if (split < 0) return [part, ''];
|
|
119
|
+
try { return [part.slice(0, split), decodeURIComponent(part.slice(split + 1))]; }
|
|
120
|
+
catch { return [part.slice(0, split), '']; }
|
|
121
|
+
}));
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
function isLocalHostName(hostname) {
|
|
@@ -115,21 +131,21 @@ function validBrowserOrigin(req) {
|
|
|
115
131
|
try {
|
|
116
132
|
const o = new URL(String(origin));
|
|
117
133
|
const host = new URL(`http://${req.headers.host ?? 'localhost'}`);
|
|
118
|
-
return
|
|
134
|
+
return o.protocol === host.protocol &&
|
|
119
135
|
isLocalHostName(o.hostname) &&
|
|
120
136
|
isLocalHostName(host.hostname) &&
|
|
121
|
-
|
|
137
|
+
o.hostname === host.hostname &&
|
|
138
|
+
o.port === host.port;
|
|
122
139
|
} catch {
|
|
123
140
|
return false;
|
|
124
141
|
}
|
|
125
142
|
}
|
|
126
143
|
|
|
127
|
-
function validCsrf(req,
|
|
144
|
+
function validCsrf(req, auth) {
|
|
128
145
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method ?? 'GET')) return true;
|
|
129
|
-
if (!req.headers.origin) return true;
|
|
146
|
+
if (auth?.kind === 'bearer' && !req.headers.origin) return true;
|
|
130
147
|
const csrf = String(req.headers['x-cockpit-csrf'] ?? '');
|
|
131
|
-
|
|
132
|
-
try { return timingSafeEqual(Buffer.from(csrf), Buffer.from(token)); } catch { return false; }
|
|
148
|
+
return constantTimeEqual(csrf, auth?.csrf ?? '');
|
|
133
149
|
}
|
|
134
150
|
|
|
135
151
|
/** Persist the per-launch token for the desktop/VS Code shells to read (mode 600). */
|
|
@@ -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
|
|
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
|
-
//
|
|
2279
|
-
|
|
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,
|
|
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
|
-
//
|
|
2650
|
-
|
|
2651
|
-
|
|
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-
|
|
2655
|
-
'
|
|
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(`
|
|
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));
|
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.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",
|
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
|
|