@aiwg/cockpit 2026.6.3
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 +337 -0
- package/bridge/package.json +13 -0
- package/bridge/src/public/index.html +395 -0
- package/bridge/src/server.mjs +1132 -0
- package/bridge/src/smoke.mjs +158 -0
- package/contrib/aiwg-core.json +15 -0
- package/contrib/contribution.schema.json +69 -0
- package/desktop/README.md +42 -0
- package/desktop/src-tauri/Cargo.toml +17 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/frontend/index.html +11 -0
- package/desktop/src-tauri/src/main.rs +55 -0
- package/desktop/src-tauri/tauri.conf.json +20 -0
- package/package.json +49 -0
- package/runtime-docs/README.md +38 -0
- package/shell-core/runtime.mjs +45 -0
- package/shell-core/smoke.mjs +36 -0
- package/vscode/README.md +25 -0
- package/vscode/extension.js +59 -0
- package/vscode/package.json +29 -0
- package/web/dist/assets/index-B5anpdS1.js +67 -0
- package/web/dist/assets/index-CP3BF6uZ.css +32 -0
- package/web/dist/index.html +14 -0
- package/web/index.html +13 -0
- package/web/package.json +31 -0
- package/web/src/App.test.tsx +237 -0
- package/web/src/App.tsx +255 -0
- package/web/src/api.ts +20 -0
- package/web/src/components/Actions.tsx +60 -0
- package/web/src/components/Approvals.tsx +62 -0
- package/web/src/components/CapabilitySearch.tsx +73 -0
- package/web/src/components/Explore.tsx +41 -0
- package/web/src/components/Inventory.tsx +124 -0
- package/web/src/components/LaunchInstanceModal.test.tsx +97 -0
- package/web/src/components/LaunchInstanceModal.tsx +236 -0
- package/web/src/components/Library.tsx +76 -0
- package/web/src/components/Running.tsx +60 -0
- package/web/src/components/Sessions.test.tsx +125 -0
- package/web/src/components/Sessions.tsx +189 -0
- package/web/src/components/StartSessionModal.test.tsx +86 -0
- package/web/src/components/StartSessionModal.tsx +168 -0
- package/web/src/components/Welcome.tsx +474 -0
- package/web/src/main.tsx +11 -0
- package/web/src/styles.css +254 -0
- package/web/src/types.ts +47 -0
- package/web/src/useDebounce.ts +10 -0
- package/web/src/useSession.ts +220 -0
- package/web/src/util.test.ts +30 -0
- package/web/src/util.ts +17 -0
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// AIWG Cockpit Bridge.
|
|
3
|
+
// Reads instance inventory from an agentic-sandbox executor admin surface and
|
|
4
|
+
// serves a minimal screen. This is the first end-to-end data path:
|
|
5
|
+
// executor (admin REST) -> Bridge (/api/inventory) -> screen.
|
|
6
|
+
// Real Bridge grows: registry/discover/index binding, per-instance A2A, pty I/O,
|
|
7
|
+
// per-launch token + OS-keychain (roctinam/aiwg#1595).
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat } from 'node:fs/promises';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, join, basename, extname, resolve, sep } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
// Primary seam for roctinam/aiwg#1589: Cockpit talks to a real agentic-sandbox
|
|
19
|
+
// executor via this URL. Mock executors are accepted only by explicit automated
|
|
20
|
+
// test opt-in, never by default dev/operator launch.
|
|
21
|
+
const EXECUTOR_URL =
|
|
22
|
+
process.env.AIWG_COCKPIT_EXECUTOR_URL ??
|
|
23
|
+
process.env.EXECUTOR_URL ??
|
|
24
|
+
'http://127.0.0.1:8122';
|
|
25
|
+
const ALLOW_MOCK_EXECUTOR = process.env.AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR === '1';
|
|
26
|
+
const AUTOSTART_EXECUTOR = process.env.AIWG_COCKPIT_AUTOSTART_EXECUTOR !== '0';
|
|
27
|
+
const EXECUTOR_COMMAND = process.env.AIWG_COCKPIT_EXECUTOR_COMMAND ?? '';
|
|
28
|
+
const RUNTIME_DIR = join(homedir(), '.aiwg', 'cockpit', 'runtime');
|
|
29
|
+
// The built React app (apps/cockpit/web/dist). Served when present; falls back to the
|
|
30
|
+
// legacy vanilla page so the Bridge works even before a web build.
|
|
31
|
+
const WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
|
|
32
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2', '.map': 'application/json' };
|
|
33
|
+
|
|
34
|
+
/** Serve a static file from the built web app, sandboxed to WEB_DIST. Returns true if served. */
|
|
35
|
+
async function serveDistFile(res, relPath) {
|
|
36
|
+
const safe = join(WEB_DIST, relPath.replace(/^\/+/, ''));
|
|
37
|
+
if (!safe.startsWith(WEB_DIST) || !existsSync(safe)) return false;
|
|
38
|
+
// content-hashed assets are safe to cache forever
|
|
39
|
+
res.writeHead(200, { 'content-type': MIME[extname(safe)] ?? 'application/octet-stream', 'cache-control': 'public, max-age=31536000, immutable' });
|
|
40
|
+
res.end(await readFile(safe));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
// First-party contribution manifests; AIWG-extension-sourced ones layer in via AIWG_COCKPIT_CONTRIB (#1591).
|
|
44
|
+
const CONTRIB_DIRS = [fileURLToPath(new URL('../../contrib', import.meta.url)), ...(process.env.AIWG_COCKPIT_CONTRIB ? [process.env.AIWG_COCKPIT_CONTRIB] : [])];
|
|
45
|
+
|
|
46
|
+
/** Constant-time bearer-token check (header or ?token=). */
|
|
47
|
+
function authed(req, url, token) {
|
|
48
|
+
const hdr = String(req.headers['authorization'] ?? '');
|
|
49
|
+
const bearer = hdr.startsWith('Bearer ') ? hdr.slice(7) : '';
|
|
50
|
+
const presented = bearer || url.searchParams.get('token') || '';
|
|
51
|
+
if (presented.length !== token.length) return false;
|
|
52
|
+
try { return timingSafeEqual(Buffer.from(presented), Buffer.from(token)); } catch { return false; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Persist the per-launch token for the desktop/VS Code shells to read (mode 600). */
|
|
56
|
+
async function writeRuntimeToken({ token, port, pid }) {
|
|
57
|
+
await mkdir(RUNTIME_DIR, { recursive: true, mode: 0o700 });
|
|
58
|
+
const file = join(RUNTIME_DIR, 'bridge.json');
|
|
59
|
+
await writeFile(file, JSON.stringify({ token, port, pid, started_at: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
60
|
+
await chmod(file, 0o600);
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
// Repo-local aiwg bin: makes the registry binding work in dev + CI without a global install.
|
|
64
|
+
const REPO_BIN = fileURLToPath(new URL('../../../../bin/aiwg.mjs', import.meta.url));
|
|
65
|
+
|
|
66
|
+
// --- registry binding: the data-driven core shells out to the aiwg CLI (#1592) ---
|
|
67
|
+
function spawnCollect(cmd, args) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const p = spawn(cmd, args, { cwd: process.cwd() }); // argv (no shell): args are not interpolated
|
|
70
|
+
let out = '', err = '';
|
|
71
|
+
p.stdout.on('data', (d) => (out += d));
|
|
72
|
+
p.stderr.on('data', (d) => (err += d));
|
|
73
|
+
p.once('error', reject);
|
|
74
|
+
p.once('close', (code) => (code === 0 ? resolve(out) : reject(new Error(err.trim() || `aiwg exit ${code}`))));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function runAiwg(args) {
|
|
78
|
+
try { return await spawnCollect('aiwg', args); }
|
|
79
|
+
catch (e) { if (e && e.code === 'ENOENT') return spawnCollect(process.execPath, [REPO_BIN, ...args]); throw e; }
|
|
80
|
+
}
|
|
81
|
+
// --- user asset library (#1591/#1593): the operator's OWN copied/cloned/imported
|
|
82
|
+
// assets, on disk under ~/.aiwg/cockpit/library. AIWG install files are NEVER written
|
|
83
|
+
// (clone reads the catalog read-only, writes only into the library). ---
|
|
84
|
+
const LIBRARY_DIR = join(homedir(), '.aiwg', 'cockpit', 'library');
|
|
85
|
+
/** Resolve a name to a path INSIDE the library, or null if it would escape. */
|
|
86
|
+
function inLibrary(name) {
|
|
87
|
+
const r = join(LIBRARY_DIR, String(name).replace(/^[/\\]+/, ''));
|
|
88
|
+
return r === LIBRARY_DIR || r.startsWith(LIBRARY_DIR + '/') ? r : null;
|
|
89
|
+
}
|
|
90
|
+
async function listLibrary() {
|
|
91
|
+
let entries;
|
|
92
|
+
try { entries = await readdir(LIBRARY_DIR, { withFileTypes: true }); } catch { return []; }
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
if (e.name.startsWith('.')) continue;
|
|
96
|
+
let meta = { name: e.name, kind: e.isDirectory() ? 'dir' : 'file', type: 'unknown', origin: 'imported' };
|
|
97
|
+
if (e.isDirectory()) {
|
|
98
|
+
try { meta = { ...meta, ...JSON.parse(await readFile(join(LIBRARY_DIR, e.name, '.cockpit-origin.json'), 'utf8')), name: e.name, kind: 'dir' }; } catch { /* no manifest */ }
|
|
99
|
+
}
|
|
100
|
+
out.push(meta);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Clone a catalog asset (skill dir or single file) into the library — never the reverse. */
|
|
105
|
+
async function cloneToLibrary({ type, name, path }) {
|
|
106
|
+
if (!type || !name || !path) throw new Error('type, name, path required');
|
|
107
|
+
if (!existsSync(path)) throw new Error('source not found');
|
|
108
|
+
await mkdir(LIBRARY_DIR, { recursive: true, mode: 0o755 });
|
|
109
|
+
const destName = String(name).replace(/[^a-z0-9._-]/gi, '-');
|
|
110
|
+
const isDir = /SKILL\.(md|markdown)$/i.test(basename(path)) || (await stat(path)).isDirectory();
|
|
111
|
+
const src = /SKILL\.(md|markdown)$/i.test(basename(path)) ? dirname(path) : path;
|
|
112
|
+
if (isDir) {
|
|
113
|
+
const dest = inLibrary(destName);
|
|
114
|
+
if (!dest || existsSync(dest)) throw new Error(`already in library: ${destName}`);
|
|
115
|
+
await cp(src, dest, { recursive: true });
|
|
116
|
+
await writeFile(join(dest, '.cockpit-origin.json'), JSON.stringify({ name: destName, type, origin: 'aiwg-catalog', kind: 'dir', source_path: path, cloned_at: new Date().toISOString() }, null, 2), { mode: 0o644 });
|
|
117
|
+
return { name: destName, type, kind: 'dir' };
|
|
118
|
+
}
|
|
119
|
+
const dest = inLibrary(destName + (extname(path) || '.md'));
|
|
120
|
+
if (!dest || existsSync(dest)) throw new Error(`already in library: ${destName}`);
|
|
121
|
+
await cp(src, dest);
|
|
122
|
+
return { name: basename(dest), type, kind: 'file' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The `aiwg show <type> <name>` slug for a discover result path. */
|
|
126
|
+
function deriveName(path) {
|
|
127
|
+
const base = basename(path);
|
|
128
|
+
if (/^SKILL\.(md|markdown)$/i.test(base)) return basename(dirname(path));
|
|
129
|
+
return base.replace(/\.(md|markdown|ya?ml|json)$/i, '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// /api/show resolution by PATH (#1643). `aiwg show <type> <name>` is ambiguous when
|
|
133
|
+
// two artifacts share a name (e.g. two `aiwg-steward` agents) — it exits non-zero on
|
|
134
|
+
// stderr, which the Bridge would otherwise surface as a 502. `discover` already returns
|
|
135
|
+
// the exact path, so the Bridge reads that corpus file directly: deterministic, no
|
|
136
|
+
// ambiguity. The path is constrained to the AIWG corpus root(s) to prevent traversal.
|
|
137
|
+
const SHOW_EXT_RE = /\.(md|markdown|ya?ml|json)$/i;
|
|
138
|
+
const CORPUS_ROOTS = [dirname(dirname(REPO_BIN)), process.env.AIWG_ROOT]
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.map((r) => resolve(r));
|
|
141
|
+
/** Resolve a discover-provided path to an absolute corpus file, or null if it escapes. */
|
|
142
|
+
function resolveCorpusPath(p) {
|
|
143
|
+
let abs;
|
|
144
|
+
try { abs = resolve(String(p)); } catch { return null; }
|
|
145
|
+
if (!SHOW_EXT_RE.test(abs)) return null;
|
|
146
|
+
if (!CORPUS_ROOTS.some((root) => abs === root || abs.startsWith(root + sep))) return null;
|
|
147
|
+
return abs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- UI contribution model (#1591): declarative screens/actions/event-hooks ---
|
|
151
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
152
|
+
/** Validate one contribution manifest. Throws with a precise message on bad shape. */
|
|
153
|
+
function validateContribution(m, where) {
|
|
154
|
+
const fail = (msg) => { throw new Error(`${where}: ${msg}`); };
|
|
155
|
+
if (!m || typeof m !== 'object') fail('manifest must be an object');
|
|
156
|
+
if (!ID_RE.test(m.id || '')) fail('id must match [a-z0-9._-]{1,64}');
|
|
157
|
+
if (typeof m.version !== 'string') fail('version (string) required');
|
|
158
|
+
const c = m.contributes || {};
|
|
159
|
+
for (const a of c.actions || []) {
|
|
160
|
+
if (!ID_RE.test(a.id || '')) fail(`action.id invalid: ${a.id}`);
|
|
161
|
+
if (typeof a.title !== 'string') fail(`action ${a.id}: title required`);
|
|
162
|
+
// An action INJECTS a command into an agentic session — it does NOT run the CLI.
|
|
163
|
+
if (!a.inject || typeof a.inject.command !== 'string') fail(`action ${a.id}: inject.command (string) required`);
|
|
164
|
+
if (a.inject.target && !['focused', 'new'].includes(a.inject.target)) fail(`action ${a.id}: inject.target must be focused|new`);
|
|
165
|
+
}
|
|
166
|
+
for (const s of c.screens || []) { if (!ID_RE.test(s.id || '') || typeof s.source !== 'string') fail(`screen invalid: ${s.id}`); }
|
|
167
|
+
for (const h of c.hooks || []) { if (typeof h.on !== 'string' || !ID_RE.test(h.action || '')) fail(`hook invalid: on=${h.on}`); }
|
|
168
|
+
return m;
|
|
169
|
+
}
|
|
170
|
+
/** Load + validate + merge all contribution manifests across the configured dirs. */
|
|
171
|
+
async function loadContributions() {
|
|
172
|
+
const sources = [], actions = [], screens = [], hooks = [];
|
|
173
|
+
for (const dir of CONTRIB_DIRS) {
|
|
174
|
+
let entries = [];
|
|
175
|
+
try { entries = (await readdir(dir)).filter((f) => f.endsWith('.json') && f !== 'contribution.schema.json'); } catch { continue; }
|
|
176
|
+
for (const file of entries) {
|
|
177
|
+
const m = validateContribution(JSON.parse(await readFile(join(dir, file), 'utf8')), file);
|
|
178
|
+
sources.push({ id: m.id, version: m.version, title: m.title ?? m.id, file });
|
|
179
|
+
for (const a of m.contributes?.actions || []) actions.push({ ...a, source: m.id });
|
|
180
|
+
for (const s of m.contributes?.screens || []) screens.push({ ...s, source: m.id });
|
|
181
|
+
for (const h of m.contributes?.hooks || []) hooks.push({ ...h, source: m.id });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { sources, actions, screens, hooks };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function json(res, status, body) {
|
|
188
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify(body));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Forward a control-plane call to the executor admin surface, relaying status + body. */
|
|
193
|
+
async function proxy(res, method, target) {
|
|
194
|
+
const r = await fetch(target, { method });
|
|
195
|
+
const body = await r.json().catch(() => ({}));
|
|
196
|
+
return json(res, r.status, body);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption } = {}) {
|
|
200
|
+
const failures = [];
|
|
201
|
+
for (const candidate of candidates) {
|
|
202
|
+
const target = typeof candidate === 'string' ? candidate : candidate.target;
|
|
203
|
+
const requestMethod = typeof candidate === 'string' ? method : candidate.method ?? method;
|
|
204
|
+
const requestHeaders = typeof candidate === 'string' ? headers : candidate.headers ?? headers;
|
|
205
|
+
const requestBody = typeof candidate === 'string' ? requestBodyOption : candidate.body ?? requestBodyOption;
|
|
206
|
+
let r;
|
|
207
|
+
try {
|
|
208
|
+
r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
failures.push(`${target} -> ${String(err?.message ?? err)}`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const responseBody = await r.json().catch(() => ({}));
|
|
214
|
+
if (r.ok) return { target, status: r.status, body: responseBody };
|
|
215
|
+
failures.push(`${target} -> ${r.status}`);
|
|
216
|
+
if (r.status !== 404 && r.status !== 405) return { target, status: r.status, body: responseBody, failures };
|
|
217
|
+
}
|
|
218
|
+
throw new Error(failures.join('; ') || 'no upstream candidates');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function mockExecutorReason(body) {
|
|
222
|
+
if (!body || typeof body !== 'object') return '';
|
|
223
|
+
const value = body;
|
|
224
|
+
if (value.mock === true) return 'health.mock=true';
|
|
225
|
+
if (String(value.name ?? '').toLowerCase().includes('mock')) return `health.name=${value.name}`;
|
|
226
|
+
if (Array.isArray(value.surfaces) && value.surfaces.includes('discovery') && value.surfaces.includes('admin')) {
|
|
227
|
+
return 'legacy mock health surfaces';
|
|
228
|
+
}
|
|
229
|
+
return '';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function assertRealExecutor(executorUrl, allowMockExecutor) {
|
|
233
|
+
if (allowMockExecutor) return;
|
|
234
|
+
let health;
|
|
235
|
+
try {
|
|
236
|
+
health = await fetchJsonFirst([`${executorUrl}/health`]);
|
|
237
|
+
} catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const reason = mockExecutorReason(health.body);
|
|
241
|
+
if (reason) {
|
|
242
|
+
const err = new Error(`mock executor refused for dev/operator launch (${reason}); use a real agentic-sandbox executor or set AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR=1 only inside automated tests`);
|
|
243
|
+
err.code = 'mock_executor_refused';
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function probeExecutor(executorUrl) {
|
|
249
|
+
for (const path of ['/healthz/http', '/healthz', '/health']) {
|
|
250
|
+
try {
|
|
251
|
+
const r = await fetch(`${executorUrl}${path}`, { signal: AbortSignal.timeout(1_500) });
|
|
252
|
+
if (r.ok) return true;
|
|
253
|
+
} catch {
|
|
254
|
+
// Try the next health endpoint.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function defaultExecutorCommand() {
|
|
261
|
+
if (EXECUTOR_COMMAND) return EXECUTOR_COMMAND.split(/\s+/).filter(Boolean);
|
|
262
|
+
const candidates = [
|
|
263
|
+
'/home/roctinam/dev/agentic-sandbox/management/target/release/agentic-mgmt',
|
|
264
|
+
'/home/roctinam/dev/agentic-sandbox/management/target/debug/agentic-mgmt',
|
|
265
|
+
'agentic-mgmt',
|
|
266
|
+
];
|
|
267
|
+
for (const c of candidates) {
|
|
268
|
+
if (c === 'agentic-mgmt' || existsSync(c)) return [c];
|
|
269
|
+
}
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function ensureExecutor(executorUrl) {
|
|
274
|
+
if (!AUTOSTART_EXECUTOR || await probeExecutor(executorUrl)) return;
|
|
275
|
+
const cmd = defaultExecutorCommand();
|
|
276
|
+
if (!cmd.length) return;
|
|
277
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
278
|
+
detached: true,
|
|
279
|
+
stdio: 'ignore',
|
|
280
|
+
env: { ...process.env },
|
|
281
|
+
});
|
|
282
|
+
child.unref();
|
|
283
|
+
for (let i = 0; i < 30; i += 1) {
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
285
|
+
if (await probeExecutor(executorUrl)) return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function proxyFirst(res, candidates, options) {
|
|
290
|
+
try {
|
|
291
|
+
const { status, body } = await fetchJsonFirst(candidates, options);
|
|
292
|
+
return json(res, status, body);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
const message = String(err?.message ?? err);
|
|
295
|
+
const notFound = / -> 404(?:;|$)/.test(message);
|
|
296
|
+
const methodNotAllowed = / -> 405(?:;|$)/.test(message);
|
|
297
|
+
return json(res, notFound ? 404 : methodNotAllowed ? 405 : 502, {
|
|
298
|
+
error: notFound ? 'upstream_not_found' : methodNotAllowed ? 'upstream_method_not_allowed' : 'bridge_upstream_error',
|
|
299
|
+
message,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function destroyInstance(upstreamUrl, instanceId) {
|
|
305
|
+
const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
|
|
306
|
+
const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
|
|
307
|
+
const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
|
|
308
|
+
const dockerName = inst?.launch_context?.name;
|
|
309
|
+
const candidates = [
|
|
310
|
+
{ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/destroy`, method: 'POST' },
|
|
311
|
+
{ target: `${upstreamUrl}/admin/instances/${encodeURIComponent(instanceId)}/destroy`, method: 'POST' },
|
|
312
|
+
{ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}`, method: 'DELETE' },
|
|
313
|
+
{ target: `${upstreamUrl}/admin/instances/${encodeURIComponent(instanceId)}`, method: 'DELETE' },
|
|
314
|
+
];
|
|
315
|
+
try {
|
|
316
|
+
const result = await fetchJsonFirst(candidates);
|
|
317
|
+
if (result.status < 400) {
|
|
318
|
+
if (['docker', 'container'].includes(runtime) && dockerName) {
|
|
319
|
+
try {
|
|
320
|
+
await spawnCollect('docker', ['rm', '-f', dockerName]);
|
|
321
|
+
return {
|
|
322
|
+
...result,
|
|
323
|
+
body: {
|
|
324
|
+
...result.body,
|
|
325
|
+
cockpit_reconcile: 'docker-cli-after-admin-v2-success',
|
|
326
|
+
docker_name: dockerName,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
} catch {
|
|
330
|
+
// If Docker already removed it, the admin result is still authoritative.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
// Current sandbox builds can list Docker rows in admin-v2 inventory while
|
|
337
|
+
// lifecycle verbs return instance.not_found. Fall through to a dev cleanup.
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!inst || !['docker', 'container'].includes(runtime) || !dockerName) {
|
|
341
|
+
return {
|
|
342
|
+
target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/destroy`,
|
|
343
|
+
status: 404,
|
|
344
|
+
body: { error: 'instance_not_destroyable', message: `No destroyable runtime record for ${instanceId}` },
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await spawnCollect('docker', ['rm', '-f', dockerName]);
|
|
349
|
+
return {
|
|
350
|
+
target: `docker rm -f ${dockerName}`,
|
|
351
|
+
status: 200,
|
|
352
|
+
body: {
|
|
353
|
+
id: instanceId,
|
|
354
|
+
name: dockerName,
|
|
355
|
+
runtime,
|
|
356
|
+
state: 'destroyed',
|
|
357
|
+
result: { state: 'destroyed' },
|
|
358
|
+
fallback: 'docker-cli-after-admin-v2-instance-not-found',
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function asArrayFromEnvelope(body, keys) {
|
|
364
|
+
if (Array.isArray(body)) return body;
|
|
365
|
+
if (!body || typeof body !== 'object') return [];
|
|
366
|
+
for (const key of keys) {
|
|
367
|
+
if (Array.isArray(body[key])) return body[key];
|
|
368
|
+
}
|
|
369
|
+
if (body.data && typeof body.data === 'object') {
|
|
370
|
+
for (const key of keys) {
|
|
371
|
+
if (Array.isArray(body.data[key])) return body.data[key];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function resolveSessionAgentId(executorUrl, instanceId) {
|
|
378
|
+
try {
|
|
379
|
+
const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
|
|
380
|
+
const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
381
|
+
const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(instanceId));
|
|
382
|
+
return agent?.id ?? agent?.agent_id ?? agent?.agentId ?? instanceId;
|
|
383
|
+
} catch {
|
|
384
|
+
return instanceId;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function unique(values) {
|
|
389
|
+
return [...new Set(values.filter(Boolean))];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function defaultSshPublicKey() {
|
|
393
|
+
const candidates = [
|
|
394
|
+
join(homedir(), '.ssh', 'agentic_ed25519.pub'),
|
|
395
|
+
join(homedir(), '.ssh', 'vm_ed25519.pub'),
|
|
396
|
+
join(homedir(), '.ssh', 'id_ed25519.pub'),
|
|
397
|
+
join(homedir(), '.ssh', 'id_rsa.pub'),
|
|
398
|
+
join(homedir(), '.ssh', 'id_ecdsa.pub'),
|
|
399
|
+
];
|
|
400
|
+
return candidates.find((path) => existsSync(path)) ?? '';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function expandHome(path) {
|
|
404
|
+
if (path === '~') return homedir();
|
|
405
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2));
|
|
406
|
+
return path;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function normalizeRuntimePosture(kind) {
|
|
410
|
+
const runtime = String(kind || 'unknown').toLowerCase();
|
|
411
|
+
if (runtime === 'host') return {
|
|
412
|
+
kind: runtime,
|
|
413
|
+
isolation: 'least',
|
|
414
|
+
label: 'Host / full host access',
|
|
415
|
+
warning: 'Least isolated tier: this agent runs with direct host access.',
|
|
416
|
+
};
|
|
417
|
+
if (runtime === 'container' || runtime === 'docker') return {
|
|
418
|
+
kind: runtime,
|
|
419
|
+
isolation: 'shared-kernel',
|
|
420
|
+
label: 'Container / shared kernel',
|
|
421
|
+
warning: 'Container isolation shares the host kernel.',
|
|
422
|
+
};
|
|
423
|
+
if (runtime === 'vm') return { kind: runtime, isolation: 'strong', label: 'VM / hardware boundary' };
|
|
424
|
+
if (runtime === 'unknown') return { kind: runtime, isolation: 'unknown', label: 'Unknown runtime', warning: 'Runtime metadata was not reported by the sandbox.' };
|
|
425
|
+
return {
|
|
426
|
+
kind: runtime,
|
|
427
|
+
isolation: 'opaque',
|
|
428
|
+
label: `${runtime} / opaque runtime`,
|
|
429
|
+
warning: 'Future or unrecognized runtime kind; Cockpit is rendering it conservatively.',
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function normalizeHostDaemon(status, runtime) {
|
|
434
|
+
const raw = status && typeof status === 'object' ? status : {};
|
|
435
|
+
const value = String(raw.status ?? (runtime === 'host' ? 'unknown' : 'unavailable')).toLowerCase();
|
|
436
|
+
const allowed = new Set(['detected', 'available', 'unavailable', 'permission_denied', 'degraded', 'stopped', 'unknown']);
|
|
437
|
+
return {
|
|
438
|
+
status: allowed.has(value) ? value : 'unknown',
|
|
439
|
+
detail: raw.detail ?? (runtime === 'host' ? 'Host daemon status was not reported.' : 'Not applicable for this runtime tier.'),
|
|
440
|
+
operator_command: raw.operator_command,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function normalizeTransport(posture) {
|
|
445
|
+
const raw = typeof posture === 'string' ? { mode: posture } : (posture && typeof posture === 'object' ? posture : {});
|
|
446
|
+
const mode = String(raw.mode ?? 'unknown');
|
|
447
|
+
const trust = String(raw.trust ?? raw.transport_posture ?? raw.posture ?? '').toLowerCase();
|
|
448
|
+
const normalizedTrust = ['secure', 'local', 'compatibility', 'degraded', 'unknown'].includes(trust) ? trust : (
|
|
449
|
+
/mtls|local-ca|client-cert/i.test(mode) ? 'secure' :
|
|
450
|
+
/shared-secret|tofu|legacy/i.test(mode) ? 'compatibility' :
|
|
451
|
+
/loopback|uds|vsock/i.test(mode) ? 'local' :
|
|
452
|
+
'unknown'
|
|
453
|
+
);
|
|
454
|
+
const labels = {
|
|
455
|
+
secure: 'Secure transport',
|
|
456
|
+
local: 'Local transport',
|
|
457
|
+
compatibility: 'Legacy compatibility',
|
|
458
|
+
degraded: 'Degraded transport',
|
|
459
|
+
unknown: 'Unknown transport',
|
|
460
|
+
};
|
|
461
|
+
return {
|
|
462
|
+
mode,
|
|
463
|
+
trust: normalizedTrust,
|
|
464
|
+
label: labels[normalizedTrust],
|
|
465
|
+
source: raw.source ?? 'agentic-sandbox metadata',
|
|
466
|
+
evidence: raw.evidence,
|
|
467
|
+
stale: Boolean(raw.stale),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agentReady = false) {
|
|
472
|
+
const list = Array.isArray(backends) ? backends : [];
|
|
473
|
+
if (!list.length && runtimeKind === 'host') {
|
|
474
|
+
return [{ mode: 'managed', backend: 'tmux', observe: true, drive: true, replay: false, keyframe: false, available: true, reason: 'agentic-sandbox v1 host session API default' }];
|
|
475
|
+
}
|
|
476
|
+
if (!list.length && ['docker', 'container', 'vm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
|
|
477
|
+
return [{
|
|
478
|
+
mode: 'managed',
|
|
479
|
+
backend: 'tmux',
|
|
480
|
+
observe: true,
|
|
481
|
+
drive: true,
|
|
482
|
+
replay: true,
|
|
483
|
+
keyframe: true,
|
|
484
|
+
available: agentReady,
|
|
485
|
+
reason: agentReady ? 'agentic-sandbox v1 managed session API' : 'container is running but the agent has not registered; PTY sessions are not ready',
|
|
486
|
+
}];
|
|
487
|
+
}
|
|
488
|
+
if (!list.length) return [{ mode: 'direct', backend: 'native', observe: true, drive: false, replay: false, keyframe: false, available: false, reason: 'sandbox did not advertise session-host capabilities' }];
|
|
489
|
+
return list.map((b) => ({
|
|
490
|
+
mode: b.mode === 'managed' ? 'managed' : 'direct',
|
|
491
|
+
backend: String(b.backend || (b.mode === 'managed' ? 'tmux' : 'native')),
|
|
492
|
+
replay: Boolean(b.replay),
|
|
493
|
+
keyframe: Boolean(b.keyframe),
|
|
494
|
+
drive: Boolean(b.drive),
|
|
495
|
+
observe: b.observe !== false,
|
|
496
|
+
available: b.available !== false,
|
|
497
|
+
reason: b.reason,
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function normalizeInstance(executorUrl, i) {
|
|
502
|
+
const runtimeValue = i.runtime_kind ?? i.runtime?.kind ?? i.runtime ?? i.runtime_tier ?? i.isolation?.runtime ?? 'unknown';
|
|
503
|
+
const runtime = String(runtimeValue);
|
|
504
|
+
const runtimePosture = normalizeRuntimePosture(runtime);
|
|
505
|
+
const id = i.instance_id ?? i.instanceId ?? i.agent_instance_id ?? i.id;
|
|
506
|
+
const loadout = i.loadout ?? i.launch_context?.loadout ?? i.launchContext?.loadout ?? i.runtime_extension?.loadout ?? i.runtimeExtension?.loadout ?? 'unknown';
|
|
507
|
+
const agentReady = Boolean(i.agent_ready ?? i.agentReady ?? i.registered_agent_id ?? i.registeredAgentId);
|
|
508
|
+
return {
|
|
509
|
+
id,
|
|
510
|
+
runtime,
|
|
511
|
+
loadout,
|
|
512
|
+
state: i.state ?? i.status ?? 'unknown',
|
|
513
|
+
tenant: i.tenant_id ?? i.tenant ?? i.tenantId ?? 'default',
|
|
514
|
+
card_url: i.card_url ?? i.cardUrl ?? `${executorUrl}/agents/${encodeURIComponent(id)}/.well-known/agent-card.json`,
|
|
515
|
+
runtime_posture: runtimePosture,
|
|
516
|
+
host_daemon: normalizeHostDaemon(i.host_daemon ?? i.hostDaemon, runtimePosture.kind),
|
|
517
|
+
transport: normalizeTransport(
|
|
518
|
+
typeof i.transport === 'string' || typeof i.transport_posture === 'string'
|
|
519
|
+
? { mode: i.transport, trust: i.transport_posture, source: 'agentic-sandbox admin-v2' }
|
|
520
|
+
: i.transport ?? i.transport_posture ?? i.security_posture ?? i.security?.transport,
|
|
521
|
+
),
|
|
522
|
+
launch_context: {
|
|
523
|
+
cwd: i.launch_context?.cwd ?? i.launchContext?.cwd ?? i.cwd,
|
|
524
|
+
loadout,
|
|
525
|
+
runtime_kind: i.launch_context?.runtime_kind ?? i.launchContext?.runtimeKind ?? runtime,
|
|
526
|
+
host: i.launch_context?.host ?? i.launchContext?.host ?? i.host_metadata?.hostname ?? i.hostMetadata?.hostname,
|
|
527
|
+
selected_tier: i.launch_context?.selected_tier ?? i.launchContext?.selectedTier ?? i.operator_selected_tier ?? i.operatorSelectedTier ?? runtime,
|
|
528
|
+
name: i.name ?? i.launch_context?.name ?? i.launchContext?.name,
|
|
529
|
+
image_ref: i.image_ref ?? i.imageRef ?? i.runtime_extension?.image_ref ?? i.runtimeExtension?.imageRef,
|
|
530
|
+
source: i.runtime_extension ? 'agent-card runtime extension' : i.launch_context?.source ?? i.launchContext?.source,
|
|
531
|
+
},
|
|
532
|
+
agent_ready: agentReady,
|
|
533
|
+
registered_agent_id: i.registered_agent_id ?? i.registeredAgentId,
|
|
534
|
+
session_backends: normalizeSessionBackends(i.session_backends ?? i.sessionBackends ?? i.session_host?.backends ?? i.sessionHost?.backends ?? i.capabilities?.session_backends ?? i.capabilities?.sessionBackends, runtimePosture.kind, i.state ?? i.status, agentReady),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function runtimeExtensionFromCard(card) {
|
|
539
|
+
const extensions = card?.capabilities?.extensions;
|
|
540
|
+
if (!Array.isArray(extensions)) return null;
|
|
541
|
+
const ext = extensions.find((e) => String(e?.uri ?? '').includes('/extensions/runtime/'));
|
|
542
|
+
return ext?.params && typeof ext.params === 'object' ? ext.params : null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function enrichInstanceFromAgentCard(executorUrl, instance) {
|
|
546
|
+
const id = instance.instance_id ?? instance.instanceId ?? instance.id;
|
|
547
|
+
if (!id) return instance;
|
|
548
|
+
try {
|
|
549
|
+
const { body } = await fetchJsonFirst([
|
|
550
|
+
`${executorUrl}/agents/${encodeURIComponent(id)}/.well-known/agent-card.json`,
|
|
551
|
+
]);
|
|
552
|
+
const runtimeExtension = runtimeExtensionFromCard(body);
|
|
553
|
+
if (!runtimeExtension) return instance;
|
|
554
|
+
return {
|
|
555
|
+
...instance,
|
|
556
|
+
runtime_extension: runtimeExtension,
|
|
557
|
+
loadout: instance.loadout ?? runtimeExtension.loadout,
|
|
558
|
+
image_ref: instance.image_ref ?? runtimeExtension.image_ref,
|
|
559
|
+
};
|
|
560
|
+
} catch {
|
|
561
|
+
return instance;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function getRegisteredAgents(executorUrl) {
|
|
566
|
+
try {
|
|
567
|
+
const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
|
|
568
|
+
return asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
569
|
+
} catch {
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function enrichInstanceFromAgentRegistry(instance, agents) {
|
|
575
|
+
const id = instance.instance_id ?? instance.instanceId ?? instance.id;
|
|
576
|
+
const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(id));
|
|
577
|
+
if (!agent) return { ...instance, agent_ready: false };
|
|
578
|
+
return {
|
|
579
|
+
...instance,
|
|
580
|
+
agent_ready: true,
|
|
581
|
+
registered_agent_id: agent.id ?? agent.agent_id ?? agent.agentId,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function normalizeAgentInstance(executorUrl, agent) {
|
|
586
|
+
const id = agent.instance_id ?? agent.instanceId ?? agent.id ?? agent.agent_id ?? agent.agentId;
|
|
587
|
+
return normalizeInstance(executorUrl, {
|
|
588
|
+
id,
|
|
589
|
+
instance_id: id,
|
|
590
|
+
runtime: 'host',
|
|
591
|
+
loadout: agent.loadout ?? 'host-tools',
|
|
592
|
+
state: 'running',
|
|
593
|
+
tenant: agent.tenant_id ?? agent.tenantId ?? 'default',
|
|
594
|
+
transport: {
|
|
595
|
+
mode: agent.transport?.mode ?? 'mtls-agent-registration',
|
|
596
|
+
trust: agent.transport?.trust ?? 'secure',
|
|
597
|
+
source: 'agent registry fallback',
|
|
598
|
+
evidence: agent.peer_identity ?? agent.spiffe_id ?? agent.spiffeId,
|
|
599
|
+
},
|
|
600
|
+
host_daemon: {
|
|
601
|
+
status: 'available',
|
|
602
|
+
detail: `Registered host agent ${agent.id ?? agent.agent_id ?? agent.agentId ?? id}`,
|
|
603
|
+
},
|
|
604
|
+
launch_context: {
|
|
605
|
+
loadout: agent.loadout ?? 'host-tools',
|
|
606
|
+
runtime_kind: 'host',
|
|
607
|
+
host: agent.hostname,
|
|
608
|
+
selected_tier: 'host',
|
|
609
|
+
},
|
|
610
|
+
session_backends: agent.session_backends ?? agent.sessionBackends ?? [
|
|
611
|
+
{ mode: 'managed', backend: 'tmux', observe: true, drive: true, replay: false, keyframe: false, available: true, reason: 'agent registry fallback' },
|
|
612
|
+
],
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function getAgentBackedHostInventory(executorUrl, degradedDetail) {
|
|
617
|
+
const { target, body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
|
|
618
|
+
const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
619
|
+
const instances = agents
|
|
620
|
+
.filter((agent) => agent.instance_id || agent.instanceId || agent.id || agent.agent_id || agent.agentId)
|
|
621
|
+
.map((agent) => normalizeAgentInstance(executorUrl, agent));
|
|
622
|
+
return {
|
|
623
|
+
source: executorUrl,
|
|
624
|
+
admin_path: new URL(target).pathname,
|
|
625
|
+
fetched_at: new Date().toISOString(),
|
|
626
|
+
count: instances.length,
|
|
627
|
+
degraded_admin_inventory: degradedDetail,
|
|
628
|
+
instances,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Normalize the executor's admin inventory into the Bridge's UI shape. */
|
|
633
|
+
async function getInventory(executorUrl) {
|
|
634
|
+
const { target, status, body } = await fetchJsonFirst([
|
|
635
|
+
`${executorUrl}/admin/instances`,
|
|
636
|
+
`${executorUrl}/api/v2/admin/instances`,
|
|
637
|
+
]);
|
|
638
|
+
const instances = asArrayFromEnvelope(body, ['instances', 'items', 'data']);
|
|
639
|
+
if (status >= 400 || !instances.length) {
|
|
640
|
+
const detail = status >= 400 ? `${new URL(target).pathname} returned ${status}` : `${new URL(target).pathname} returned no instances`;
|
|
641
|
+
try {
|
|
642
|
+
const fallback = await getAgentBackedHostInventory(executorUrl, detail);
|
|
643
|
+
if (fallback.instances.length) return fallback;
|
|
644
|
+
} catch {
|
|
645
|
+
// Preserve the admin inventory result when no agent-backed fallback is available.
|
|
646
|
+
}
|
|
647
|
+
if (status >= 400) {
|
|
648
|
+
return {
|
|
649
|
+
source: executorUrl,
|
|
650
|
+
admin_path: new URL(target).pathname,
|
|
651
|
+
fetched_at: new Date().toISOString(),
|
|
652
|
+
count: 0,
|
|
653
|
+
degraded_admin_inventory: detail,
|
|
654
|
+
admin_error: body,
|
|
655
|
+
instances: [],
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const agents = await getRegisteredAgents(executorUrl);
|
|
660
|
+
const enriched = await Promise.all(instances.map((i) => enrichInstanceFromAgentCard(executorUrl, i)));
|
|
661
|
+
const normalized = enriched
|
|
662
|
+
.map((i) => enrichInstanceFromAgentRegistry(i, agents))
|
|
663
|
+
.map((i) => normalizeInstance(executorUrl, i));
|
|
664
|
+
return {
|
|
665
|
+
source: executorUrl,
|
|
666
|
+
admin_path: new URL(target).pathname,
|
|
667
|
+
fetched_at: new Date().toISOString(),
|
|
668
|
+
count: normalized.length,
|
|
669
|
+
instances: normalized,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// --- task derivation (#1639) -------------------------------------------------
|
|
674
|
+
// The real agentic-sandbox v2 admin surface has NO /running or /approvals route.
|
|
675
|
+
// The running board and the approval inbox are derived from the real A2A task
|
|
676
|
+
// surface (`/agents/{agentId}/tasks`) per instance — not from the mock's invented
|
|
677
|
+
// /admin/running. A2A task lifecycle states: submitted/working/input-required are
|
|
678
|
+
// active; completed/canceled/failed/rejected are terminal.
|
|
679
|
+
const ACTIVE_TASK_STATES = new Set(['submitted', 'working', 'input-required', 'in_progress', 'running']);
|
|
680
|
+
const taskState = (t) => t.status?.state ?? t.state ?? (typeof t.status === 'string' ? t.status : 'unknown');
|
|
681
|
+
const taskIdOf = (t) => t.id ?? t.task_id ?? t.taskId;
|
|
682
|
+
const taskTenantOf = (t) => t.metadata?.tenant_id ?? t.metadata?.tenantId ?? t.tenant ?? t.tenant_id ?? t.tenantId ?? 'default';
|
|
683
|
+
|
|
684
|
+
/** Active tasks for one instance via the A2A task surface (#1639). The session
|
|
685
|
+
* agent id (not the instance id) keys the agent routes on real executors. */
|
|
686
|
+
async function listInstanceTasks(executorUrl, instanceId) {
|
|
687
|
+
const agentId = await resolveSessionAgentId(executorUrl, instanceId);
|
|
688
|
+
const candidates = unique([instanceId, agentId]).flatMap((id) => [
|
|
689
|
+
`${executorUrl}/agents/${encodeURIComponent(id)}/tasks`,
|
|
690
|
+
`${executorUrl}/api/v1/agents/${encodeURIComponent(id)}/tasks`,
|
|
691
|
+
]);
|
|
692
|
+
const { body } = await fetchJsonFirst(candidates);
|
|
693
|
+
return asArrayFromEnvelope(body, ['tasks', 'items', 'data']);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Running board derived from active A2A tasks across running instances (#1639).
|
|
697
|
+
* An instance with no reachable task surface contributes nothing rather than
|
|
698
|
+
* failing the whole board (so a real executor stays usable). */
|
|
699
|
+
// Loadout catalog passthrough (#1641). The real executor exposes GET /api/v1/loadouts
|
|
700
|
+
// (and v2 /loadouts); the mock mirrors it under /admin/loadouts. Normalized to a flat
|
|
701
|
+
// {id,label,description,runtimes} list so the start-session picker can offer the full set
|
|
702
|
+
// (vs. only echoing the instance's own loadout field).
|
|
703
|
+
async function getLoadouts(executorUrl) {
|
|
704
|
+
const { target, body } = await fetchJsonFirst([
|
|
705
|
+
`${executorUrl}/api/v1/loadouts`,
|
|
706
|
+
`${executorUrl}/api/v2/loadouts`,
|
|
707
|
+
`${executorUrl}/loadouts`,
|
|
708
|
+
`${executorUrl}/admin/loadouts`,
|
|
709
|
+
]);
|
|
710
|
+
const raw = asArrayFromEnvelope(body, ['loadouts', 'items', 'data']);
|
|
711
|
+
const loadouts = raw.map((l) => {
|
|
712
|
+
if (typeof l === 'string') return { id: l, label: l };
|
|
713
|
+
const id = l.id ?? l.name ?? l.loadout ?? l.slug;
|
|
714
|
+
return {
|
|
715
|
+
id,
|
|
716
|
+
label: l.label ?? l.display_name ?? l.displayName ?? id,
|
|
717
|
+
description: l.description ?? l.summary,
|
|
718
|
+
runtimes: l.runtimes ?? l.runtime_kinds ?? l.supported_runtimes,
|
|
719
|
+
};
|
|
720
|
+
}).filter((l) => l.id);
|
|
721
|
+
return { source: executorUrl, loadouts_path: new URL(target).pathname, count: loadouts.length, loadouts };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function getRunning(executorUrl) {
|
|
725
|
+
const instances = (await getInventory(executorUrl)).instances;
|
|
726
|
+
const running = [];
|
|
727
|
+
await Promise.all(
|
|
728
|
+
instances.filter((i) => i.state === 'running').map(async (inst) => {
|
|
729
|
+
let tasks;
|
|
730
|
+
try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
|
|
731
|
+
for (const t of tasks) {
|
|
732
|
+
const state = taskState(t);
|
|
733
|
+
if (!ACTIVE_TASK_STATES.has(state)) continue;
|
|
734
|
+
running.push({
|
|
735
|
+
instance_id: inst.id,
|
|
736
|
+
task_id: taskIdOf(t),
|
|
737
|
+
state,
|
|
738
|
+
tenant: taskTenantOf(t),
|
|
739
|
+
runtime_posture: inst.runtime_posture,
|
|
740
|
+
transport: inst.transport,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}),
|
|
744
|
+
);
|
|
745
|
+
return {
|
|
746
|
+
source: executorUrl,
|
|
747
|
+
fetched_at: new Date().toISOString(),
|
|
748
|
+
count: running.length,
|
|
749
|
+
running,
|
|
750
|
+
derived: 'per-instance A2A tasks',
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Pending HITL approvals (the unified approval inbox). The real agentic-sandbox
|
|
756
|
+
* v2 admin surface has no /approvals route — HITL prompts arrive via A2A
|
|
757
|
+
* `input-required` / `hitl-prompt/v1`. Deriving the inbox (and routing the
|
|
758
|
+
* decision back to the task) from that surface is the remaining half of the v2
|
|
759
|
+
* work (#1639 follow-up, with #1565); until then degrade to an empty inbox
|
|
760
|
+
* rather than 404 so the operator Home view stays usable against a real executor.
|
|
761
|
+
*/
|
|
762
|
+
async function getApprovals(executorUrl, status) {
|
|
763
|
+
let body;
|
|
764
|
+
try {
|
|
765
|
+
({ body } = await fetchJsonFirst([
|
|
766
|
+
`${executorUrl}/admin/approvals?status=${encodeURIComponent(status)}`,
|
|
767
|
+
`${executorUrl}/api/v2/admin/approvals?status=${encodeURIComponent(status)}`,
|
|
768
|
+
]));
|
|
769
|
+
} catch {
|
|
770
|
+
return {
|
|
771
|
+
source: executorUrl,
|
|
772
|
+
fetched_at: new Date().toISOString(),
|
|
773
|
+
approvals: [],
|
|
774
|
+
derived: 'executor exposes no admin approvals endpoint',
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
source: executorUrl,
|
|
779
|
+
fetched_at: new Date().toISOString(),
|
|
780
|
+
approvals: asArrayFromEnvelope(body, ['approvals', 'items', 'data']),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Sessions for one instance, each with a direct attach_url. Control plane (this
|
|
786
|
+
* list) goes through the Bridge; the data plane (the pty stream) connects direct
|
|
787
|
+
* to the executor — masking differs per WS direction, so the Bridge issues the
|
|
788
|
+
* URL rather than proxying frames.
|
|
789
|
+
*/
|
|
790
|
+
async function getSessions(executorUrl, instanceId) {
|
|
791
|
+
const sessionAgentId = await resolveSessionAgentId(executorUrl, instanceId);
|
|
792
|
+
const agentIds = unique([instanceId, sessionAgentId]);
|
|
793
|
+
const { body } = await fetchJsonFirst(agentIds.flatMap((agentId) => [
|
|
794
|
+
`${executorUrl}/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
795
|
+
`${executorUrl}/agents/${encodeURIComponent(agentId)}/v1/sessions`,
|
|
796
|
+
`${executorUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
797
|
+
]));
|
|
798
|
+
const sessions = asArrayFromEnvelope(body, ['sessions', 'items', 'data']);
|
|
799
|
+
const wsBase = executorUrl.replace(/^http/i, 'ws');
|
|
800
|
+
const normalizeAttachUrl = (s, sessionId) => {
|
|
801
|
+
const explicit = s.attach_url ?? s.attachUrl;
|
|
802
|
+
if (explicit) return explicit;
|
|
803
|
+
const ptyUrl = s.pty_ws_url ?? s.ptyWsUrl;
|
|
804
|
+
if (ptyUrl) {
|
|
805
|
+
try {
|
|
806
|
+
const u = new URL(String(ptyUrl).replace('{host}', new URL(executorUrl).host));
|
|
807
|
+
u.protocol = new URL(executorUrl).protocol === 'https:' ? 'wss:' : 'ws:';
|
|
808
|
+
return u.toString();
|
|
809
|
+
} catch { /* fall through to legacy shape */ }
|
|
810
|
+
}
|
|
811
|
+
return `${wsBase}/agents/${encodeURIComponent(sessionAgentId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
|
|
812
|
+
};
|
|
813
|
+
return {
|
|
814
|
+
instance_id: instanceId,
|
|
815
|
+
sessions: sessions.map((s) => {
|
|
816
|
+
const sessionId = s.id ?? s.session_id ?? s.sessionId;
|
|
817
|
+
return {
|
|
818
|
+
...s,
|
|
819
|
+
id: sessionId,
|
|
820
|
+
instance_id: s.instance_id ?? s.instanceId ?? instanceId,
|
|
821
|
+
agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
|
|
822
|
+
role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
|
|
823
|
+
attach_url: normalizeAttachUrl(s, sessionId),
|
|
824
|
+
};
|
|
825
|
+
}),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function endSession(executorUrl, instanceId, sessionId) {
|
|
830
|
+
const sessionAgentId = await resolveSessionAgentId(executorUrl, instanceId);
|
|
831
|
+
let sessions = [];
|
|
832
|
+
try {
|
|
833
|
+
sessions = (await getSessions(executorUrl, instanceId)).sessions;
|
|
834
|
+
} catch {
|
|
835
|
+
// Fall back to using the supplied id directly; older executors may not list
|
|
836
|
+
// before delete, and delete should remain useful during recovery cleanup.
|
|
837
|
+
}
|
|
838
|
+
const targetSession = sessions.find((s) => String(s.id) === String(sessionId)
|
|
839
|
+
|| String(s.session_id ?? s.sessionId ?? '') === String(sessionId)
|
|
840
|
+
|| String(s.session_name ?? s.sessionName ?? '') === String(sessionId));
|
|
841
|
+
const sessionName = targetSession?.session_name ?? targetSession?.sessionName ?? sessionId;
|
|
842
|
+
const { status, body } = await fetchJsonFirst(unique([sessionAgentId, instanceId]).map((agentId) => ({
|
|
843
|
+
target: `${executorUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions/${encodeURIComponent(sessionName)}`,
|
|
844
|
+
method: 'DELETE',
|
|
845
|
+
})));
|
|
846
|
+
return {
|
|
847
|
+
status,
|
|
848
|
+
body: {
|
|
849
|
+
...body,
|
|
850
|
+
id: sessionId,
|
|
851
|
+
session_name: sessionName,
|
|
852
|
+
instance_id: instanceId,
|
|
853
|
+
agent_id: sessionAgentId,
|
|
854
|
+
ended: status >= 200 && status < 300,
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = ALLOW_MOCK_EXECUTOR, token } = {}) {
|
|
860
|
+
const upstreamUrl = executorUrl;
|
|
861
|
+
const TOKEN = token ?? randomBytes(24).toString('hex');
|
|
862
|
+
const server = http.createServer(async (req, res) => {
|
|
863
|
+
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
|
|
864
|
+
try {
|
|
865
|
+
// unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
|
|
866
|
+
if (url.pathname === '/healthz') return json(res, 200, { status: 'ok' });
|
|
867
|
+
// gate the control surface: per-launch bearer token on every /api/ call
|
|
868
|
+
if (url.pathname.startsWith('/api/') && !authed(req, url, TOKEN)) {
|
|
869
|
+
return json(res, 401, { error: 'unauthorized', detail: 'missing or invalid cockpit token' });
|
|
870
|
+
}
|
|
871
|
+
if (url.pathname.startsWith('/api/')) {
|
|
872
|
+
try {
|
|
873
|
+
await assertRealExecutor(upstreamUrl, allowMockExecutor);
|
|
874
|
+
} catch (err) {
|
|
875
|
+
return json(res, 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err) });
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
|
|
879
|
+
if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
|
|
880
|
+
if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
|
|
881
|
+
let m;
|
|
882
|
+
if (url.pathname === '/api/instances' && req.method === 'POST') {
|
|
883
|
+
const chunks = [];
|
|
884
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
885
|
+
const rawBody = Buffer.concat(chunks).toString('utf8') || '{}';
|
|
886
|
+
let payload;
|
|
887
|
+
try {
|
|
888
|
+
payload = JSON.parse(rawBody);
|
|
889
|
+
} catch {
|
|
890
|
+
return json(res, 400, { error: 'invalid_json' });
|
|
891
|
+
}
|
|
892
|
+
if (payload.runtime === 'qemu') {
|
|
893
|
+
const sshKey = expandHome(String(payload.ssh_key ?? payload.sshKey ?? '').trim()) || defaultSshPublicKey();
|
|
894
|
+
if (!sshKey) {
|
|
895
|
+
return json(res, 400, {
|
|
896
|
+
error: 'ssh_public_key_required',
|
|
897
|
+
message: 'VM / QEMU launch requires an SSH public key path on the executor host.',
|
|
898
|
+
detail: 'Create ~/.ssh/agentic_ed25519.pub or pass ssh_key in the launch request.',
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (!existsSync(sshKey)) {
|
|
902
|
+
return json(res, 400, {
|
|
903
|
+
error: 'ssh_public_key_not_found',
|
|
904
|
+
message: `SSH public key not found at ${sshKey}`,
|
|
905
|
+
detail: 'Choose an existing public key path on the executor host.',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
payload.ssh_key = sshKey;
|
|
909
|
+
}
|
|
910
|
+
const requestBody = JSON.stringify(payload);
|
|
911
|
+
return proxyFirst(res, [
|
|
912
|
+
{
|
|
913
|
+
target: `${upstreamUrl}/api/v2/admin/instances`,
|
|
914
|
+
method: 'POST',
|
|
915
|
+
headers: { 'content-type': 'application/json' },
|
|
916
|
+
body: requestBody,
|
|
917
|
+
},
|
|
918
|
+
]);
|
|
919
|
+
}
|
|
920
|
+
if ((m = url.pathname.match(/^\/api\/operations\/([^/]+)$/)) && req.method === 'GET') {
|
|
921
|
+
return proxyFirst(res, [
|
|
922
|
+
`${upstreamUrl}/api/v2/admin/operations/${encodeURIComponent(m[1])}`,
|
|
923
|
+
]);
|
|
924
|
+
}
|
|
925
|
+
if (url.pathname === '/api/sessions') {
|
|
926
|
+
const inst = url.searchParams.get('instance');
|
|
927
|
+
if (!inst) return json(res, 400, { error: 'instance_required' });
|
|
928
|
+
return json(res, 200, await getSessions(upstreamUrl, inst));
|
|
929
|
+
}
|
|
930
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)$/)) && req.method === 'DELETE') {
|
|
931
|
+
const { status, body } = await endSession(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
|
|
932
|
+
return json(res, status, body);
|
|
933
|
+
}
|
|
934
|
+
// registry-bound, data-driven core — live, no app restart (#1592)
|
|
935
|
+
if (url.pathname === '/api/capabilities') {
|
|
936
|
+
const q = (url.searchParams.get('q') || '').trim();
|
|
937
|
+
if (!q) return json(res, 400, { error: 'q_required' });
|
|
938
|
+
const args = ['discover', q, '--json', '--limit', String(Number(url.searchParams.get('limit')) || 8)];
|
|
939
|
+
const type = url.searchParams.get('type');
|
|
940
|
+
if (type && type !== 'all') args.push('--type', type);
|
|
941
|
+
const data = JSON.parse(await runAiwg(args));
|
|
942
|
+
data.results = (data.results || []).map((r) => ({ ...r, name: deriveName(r.path) }));
|
|
943
|
+
return json(res, 200, data);
|
|
944
|
+
}
|
|
945
|
+
if (url.pathname === '/api/show') {
|
|
946
|
+
const type = url.searchParams.get('type');
|
|
947
|
+
const name = url.searchParams.get('name');
|
|
948
|
+
const wantPath = url.searchParams.get('path');
|
|
949
|
+
// Preferred path: resolve by the discovered file path. Deterministic even when a
|
|
950
|
+
// name is shared by two artifacts (#1643). discover always returns this path.
|
|
951
|
+
if (wantPath) {
|
|
952
|
+
const resolved = resolveCorpusPath(wantPath);
|
|
953
|
+
if (!resolved) return json(res, 400, { error: 'path_outside_corpus' });
|
|
954
|
+
try {
|
|
955
|
+
const body = await readFile(resolved, 'utf8');
|
|
956
|
+
return json(res, 200, { type, name: name ?? deriveName(resolved), path: resolved, body });
|
|
957
|
+
} catch (e) {
|
|
958
|
+
return json(res, 404, { error: 'artifact_not_found', detail: String(e?.message ?? e) });
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// Fallback: resolve by name via the CLI. Map ambiguity/not-found to 4xx — an
|
|
962
|
+
// ambiguous name is operator-correctable input, never a Bridge 502.
|
|
963
|
+
if (!type || !name) return json(res, 400, { error: 'type_name_or_path_required' });
|
|
964
|
+
try {
|
|
965
|
+
return json(res, 200, { type, name, body: await runAiwg(['show', type, name]) });
|
|
966
|
+
} catch (e) {
|
|
967
|
+
const detail = String(e?.message ?? e);
|
|
968
|
+
const ambiguous = /ambiguous/i.test(detail);
|
|
969
|
+
return json(res, ambiguous ? 409 : 404, {
|
|
970
|
+
error: ambiguous ? 'ambiguous_artifact' : 'artifact_not_found',
|
|
971
|
+
detail,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// user asset library — browse / clone-from-catalog / delete. AIWG install files
|
|
976
|
+
// are never written; deletes are sandboxed to ~/.aiwg/cockpit/library.
|
|
977
|
+
if (url.pathname === '/api/library' && req.method === 'GET') return json(res, 200, { library: await listLibrary() });
|
|
978
|
+
if (url.pathname === '/api/library/clone' && req.method === 'POST') {
|
|
979
|
+
try {
|
|
980
|
+
return json(res, 201, await cloneToLibrary({
|
|
981
|
+
type: url.searchParams.get('type'), name: url.searchParams.get('name'), path: url.searchParams.get('path'),
|
|
982
|
+
}));
|
|
983
|
+
} catch (e) { return json(res, 400, { error: 'clone_failed', detail: String(e?.message ?? e) }); }
|
|
984
|
+
}
|
|
985
|
+
{
|
|
986
|
+
const lm = url.pathname.match(/^\/api\/library\/(.+)$/);
|
|
987
|
+
if (lm && req.method === 'DELETE') {
|
|
988
|
+
const target = inLibrary(decodeURIComponent(lm[1]));
|
|
989
|
+
if (!target || target === LIBRARY_DIR || !existsSync(target)) return json(res, 404, { error: 'not_in_library' });
|
|
990
|
+
await rm(target, { recursive: true, force: true });
|
|
991
|
+
return json(res, 200, { removed: decodeURIComponent(lm[1]) });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// contribution model — declarative UI extension (#1591). Actions INJECT a command
|
|
996
|
+
// into an agentic session (client-side, over the pty WS); the Bridge does NOT run
|
|
997
|
+
// them. See adr-cockpit-session-control-not-cli-runner.md.
|
|
998
|
+
if (url.pathname === '/api/contributions') return json(res, 200, await loadContributions());
|
|
999
|
+
// --- start a session (the onboarding primary verb): create + issue attach_url ---
|
|
1000
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions$/)) && req.method === 'POST') {
|
|
1001
|
+
const id = decodeURIComponent(m[1]);
|
|
1002
|
+
const qs = new URLSearchParams();
|
|
1003
|
+
const mode = url.searchParams.get('mode'), backend = url.searchParams.get('backend'), loadout = url.searchParams.get('loadout');
|
|
1004
|
+
if (mode) qs.set('mode', mode);
|
|
1005
|
+
if (backend) qs.set('backend', backend);
|
|
1006
|
+
if (loadout) qs.set('loadout', loadout);
|
|
1007
|
+
const sessionAgentId = await resolveSessionAgentId(upstreamUrl, id);
|
|
1008
|
+
const candidates = unique([sessionAgentId, id]).flatMap((agentId) => [
|
|
1009
|
+
{
|
|
1010
|
+
target: `${upstreamUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
1011
|
+
method: 'POST',
|
|
1012
|
+
headers: { 'content-type': 'application/json' },
|
|
1013
|
+
body: JSON.stringify({
|
|
1014
|
+
session_backend: backend || 'tmux',
|
|
1015
|
+
session_class: mode || 'managed',
|
|
1016
|
+
command: 'bash',
|
|
1017
|
+
args: ['-l'],
|
|
1018
|
+
working_dir: '/root',
|
|
1019
|
+
}),
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
target: `${upstreamUrl}/agents/${encodeURIComponent(agentId)}/sessions?${qs.toString()}`,
|
|
1023
|
+
method: 'POST',
|
|
1024
|
+
},
|
|
1025
|
+
]);
|
|
1026
|
+
let sessionCreate;
|
|
1027
|
+
try {
|
|
1028
|
+
sessionCreate = await fetchJsonFirst(candidates);
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
return json(res, 409, {
|
|
1031
|
+
error: 'agent_not_registered',
|
|
1032
|
+
message: 'The instance is visible in inventory, but its agent has not registered yet; PTY sessions are not ready.',
|
|
1033
|
+
detail: String(err?.message ?? err),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
const { status, body } = sessionCreate;
|
|
1037
|
+
const wsBase = upstreamUrl.replace(/^http/i, 'ws');
|
|
1038
|
+
const sessionId = body.id ?? body.session_id ?? body.sessionId;
|
|
1039
|
+
if (status >= 200 && status < 300 && !sessionId && !body.attach_url && !body.attachUrl && !body.pty_ws_url && !body.ptyWsUrl) {
|
|
1040
|
+
return json(res, 502, { error: 'session_create_missing_id', message: 'executor created no attachable session identifier', body });
|
|
1041
|
+
}
|
|
1042
|
+
let attachUrl = body.attach_url ?? body.attachUrl;
|
|
1043
|
+
if (!attachUrl && (body.pty_ws_url ?? body.ptyWsUrl)) {
|
|
1044
|
+
try {
|
|
1045
|
+
const u = new URL(String(body.pty_ws_url ?? body.ptyWsUrl).replace('{host}', new URL(upstreamUrl).host));
|
|
1046
|
+
u.protocol = new URL(upstreamUrl).protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1047
|
+
attachUrl = u.toString();
|
|
1048
|
+
} catch { /* fall through to legacy shape */ }
|
|
1049
|
+
}
|
|
1050
|
+
return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(sessionAgentId)}/sessions/${encodeURIComponent(sessionId)}/attach` });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// --- management surface (UC-012): lifecycle + task cancel ---
|
|
1054
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop)$/)) && req.method === 'POST')
|
|
1055
|
+
return proxyFirst(res, [
|
|
1056
|
+
`${upstreamUrl}/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
|
|
1057
|
+
`${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
|
|
1058
|
+
], { method: 'POST' });
|
|
1059
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)$/)) && req.method === 'DELETE') {
|
|
1060
|
+
const { status, body } = await destroyInstance(upstreamUrl, decodeURIComponent(m[1]));
|
|
1061
|
+
return json(res, status, body);
|
|
1062
|
+
}
|
|
1063
|
+
if ((m = url.pathname.match(/^\/api\/tasks\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST')
|
|
1064
|
+
return proxy(res, 'POST', `${upstreamUrl}/agents/${encodeURIComponent(m[1])}/tasks/${encodeURIComponent(m[2])}:cancel`);
|
|
1065
|
+
|
|
1066
|
+
// --- approval inbox (UC-009) + cost (UC-010) ---
|
|
1067
|
+
if (url.pathname === '/api/approvals' && req.method === 'GET')
|
|
1068
|
+
return json(res, 200, await getApprovals(upstreamUrl, url.searchParams.get('status') || 'pending'));
|
|
1069
|
+
if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST')
|
|
1070
|
+
return proxy(res, 'POST', `${upstreamUrl}/admin/approvals/${encodeURIComponent(m[1])}?decision=${encodeURIComponent(url.searchParams.get('decision') || '')}`);
|
|
1071
|
+
if (url.pathname === '/api/cost' && req.method === 'GET')
|
|
1072
|
+
return proxy(res, 'GET', `${upstreamUrl}/admin/cost`);
|
|
1073
|
+
|
|
1074
|
+
if (url.pathname === '/api/health') return json(res, 200, { status: 'ok', executor_url: upstreamUrl, mock_executor_allowed: allowMockExecutor });
|
|
1075
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
1076
|
+
const distIndex = join(WEB_DIST, 'index.html');
|
|
1077
|
+
const src = existsSync(distIndex) ? distIndex : join(__dir, 'public', 'index.html');
|
|
1078
|
+
const raw = await readFile(src, 'utf8');
|
|
1079
|
+
// Inject the per-launch token so the same-origin app can call the gated API.
|
|
1080
|
+
const html = raw.replace('</head>', `<script>window.__COCKPIT_TOKEN__=${JSON.stringify(TOKEN)}</script>\n</head>`);
|
|
1081
|
+
// never cache the shell — it must always reference the latest hashed bundle
|
|
1082
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
|
|
1083
|
+
return res.end(html);
|
|
1084
|
+
}
|
|
1085
|
+
// static assets from the built web app (e.g. /assets/*.js, *.css)
|
|
1086
|
+
if (req.method === 'GET' && !url.pathname.startsWith('/api/') && url.pathname !== '/healthz') {
|
|
1087
|
+
if (await serveDistFile(res, url.pathname)) return;
|
|
1088
|
+
}
|
|
1089
|
+
json(res, 404, { error: 'not_found', path: url.pathname });
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
json(res, 502, { error: 'bridge_upstream_error', message: String(err?.message ?? err) });
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
server.cockpitToken = TOKEN; // exposed for shells/tests
|
|
1095
|
+
return server;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// The agentic-sandbox canonical dev runner (`management/dev.sh`) binds
|
|
1099
|
+
// 8120 (gRPC) / 8121 (WS) / 8122 (HTTP). The Bridge must NOT default into that
|
|
1100
|
+
// range or it squats on the executor's own ports (#1634). Default off-range and
|
|
1101
|
+
// refuse to silently start on a reserved port.
|
|
1102
|
+
export const EXECUTOR_RESERVED_PORTS = [8120, 8121, 8122];
|
|
1103
|
+
export const DEFAULT_BRIDGE_PORT = 8140;
|
|
1104
|
+
|
|
1105
|
+
/** Resolve the Bridge listen port from the environment with a sane, off-range
|
|
1106
|
+
* default. Throws on an invalid port or a collision with the executor range. */
|
|
1107
|
+
export function resolveBridgePort(env = process.env) {
|
|
1108
|
+
const raw = env.PORT ?? env.AIWG_COCKPIT_BRIDGE_PORT;
|
|
1109
|
+
const port = raw === undefined || raw === '' ? DEFAULT_BRIDGE_PORT : Number(raw);
|
|
1110
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1111
|
+
throw new Error(`Invalid Bridge port: ${JSON.stringify(raw)} (set PORT to a number 1-65535).`);
|
|
1112
|
+
}
|
|
1113
|
+
if (EXECUTOR_RESERVED_PORTS.includes(port)) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
`Bridge port ${port} collides with the agentic-sandbox canonical range ` +
|
|
1116
|
+
`(${EXECUTOR_RESERVED_PORTS.join('/')} = gRPC/WS/HTTP). The executor needs that ` +
|
|
1117
|
+
`range — pick another port (default ${DEFAULT_BRIDGE_PORT}).`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
return port;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1124
|
+
const port = resolveBridgePort();
|
|
1125
|
+
await ensureExecutor(EXECUTOR_URL);
|
|
1126
|
+
const server = createBridge();
|
|
1127
|
+
server.listen(port, '127.0.0.1', async () => {
|
|
1128
|
+
const file = await writeRuntimeToken({ token: server.cockpitToken, port, pid: process.pid });
|
|
1129
|
+
console.log(`[cockpit-bridge] http://127.0.0.1:${port} (executor ${EXECUTOR_URL})`);
|
|
1130
|
+
console.log(` token written ${file} (mode 600) — open the URL in a browser or attach a shell`);
|
|
1131
|
+
});
|
|
1132
|
+
}
|