@aiwg/cockpit 2026.7.7 → 2026.7.10
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 +16 -4
- package/bridge/src/server.mjs +372 -27
- package/bridge/src/smoke.mjs +10 -4
- package/contrib/aiwg-core.json +24 -2
- package/contrib/contribution.schema.json +28 -0
- package/package.json +1 -1
- package/shell-core/keychain.mjs +3 -0
- package/shell-core/runtime.mjs +3 -0
- package/web/src/App.test.tsx +146 -2
- package/web/src/App.tsx +9 -0
- package/web/src/components/Actions.tsx +80 -3
- package/web/src/components/Explore.tsx +89 -2
- package/web/src/components/Memory.tsx +145 -0
- package/web/src/components/Missions.tsx +135 -0
- package/web/src/components/Telemetry.tsx +85 -0
- package/web/src/styles.css +51 -1
- package/web/src/types.ts +67 -0
package/README.md
CHANGED
|
@@ -60,9 +60,11 @@ AIWG_COCKPIT_EXECUTOR_URL=http://127.0.0.1:8122 aiwg cockpit
|
|
|
60
60
|
- **Running** — active work across stacks, spend posture, and task stop controls.
|
|
61
61
|
- **Sessions** — observe-first terminal attach, explicit drive/control, and replay posture.
|
|
62
62
|
- **Approvals** — unified human-in-the-loop decision inbox.
|
|
63
|
-
- **Explore** — read-only AIWG capability catalog
|
|
63
|
+
- **Explore** — live index status/query/rebuild plus read-only AIWG capability catalog.
|
|
64
64
|
- **Library** — user-owned assets cloned/imported under `~/.aiwg/cockpit/library`.
|
|
65
|
-
- **
|
|
65
|
+
- **Telemetry** — unified event feed, Mission/session/task/approval/inventory posture, and spend.
|
|
66
|
+
- **Memory** — browser-local operator notes plus Mission completion notes from MC state.
|
|
67
|
+
- **Actions** — contributed actions, first-party screens, and workflows; action steps inject commands into an agentic session.
|
|
66
68
|
|
|
67
69
|
## What Cockpit Is
|
|
68
70
|
|
|
@@ -138,9 +140,11 @@ operator / CLI: aiwg cockpit
|
|
|
138
140
|
| **Running** | Running work across stacks + cross-stack spend + per-task Stop. |
|
|
139
141
|
| **Sessions** | Live pty terminal — observe/drive, keyframe, non-destructive replay; inline **+ capability picker**. |
|
|
140
142
|
| **Approvals** | Unified HITL inbox (`hitl-prompt/v1`); decisions = operator authorization. |
|
|
141
|
-
| **Explore** |
|
|
143
|
+
| **Explore** | Live artifact-index status/query/rebuild plus read-only AIWG catalog search. |
|
|
142
144
|
| **Library** | Your own assets — clone from the catalog / import / remove. AIWG files never overwritten. |
|
|
143
|
-
| **
|
|
145
|
+
| **Telemetry** | Unified event model over inventory, sessions, tasks, approvals, Missions, and cost posture. |
|
|
146
|
+
| **Memory** | Operator notes and auto-created Mission completion notes from durable `aiwg mc` state. |
|
|
147
|
+
| **Actions** | Contributed actions, first-party screens, and workflows. Action steps **inject a command into a session** (the agent runs it). |
|
|
144
148
|
|
|
145
149
|
## Runtime, Session, and Trust Posture
|
|
146
150
|
|
|
@@ -169,6 +173,14 @@ activity payloads. Legacy shared-secret and TOFU paths render as compatibility o
|
|
|
169
173
|
degraded, not default-green. Agentic-sandbox owns transport provisioning and peer
|
|
170
174
|
identity enforcement; Cockpit owns visibility and audit presentation.
|
|
171
175
|
|
|
176
|
+
Set `AIWG_COCKPIT_KEYCHAIN_STRICT=1` when shells must refuse plaintext runtime
|
|
177
|
+
tokens. In strict mode the Bridge exits if it cannot persist the per-launch token
|
|
178
|
+
to the OS keychain, and shell-core refuses runtime files that only contain a
|
|
179
|
+
plaintext token. Operator intent is also recorded in a local redacted audit log
|
|
180
|
+
under `~/.aiwg/cockpit/audit/events.jsonl` for lifecycle, session, action-inject,
|
|
181
|
+
and approval-response decisions; bearer material and provider credentials are
|
|
182
|
+
redacted before write.
|
|
183
|
+
|
|
172
184
|
## Run (dev/test, against a real agentic-sandbox executor)
|
|
173
185
|
|
|
174
186
|
One command (#1634) — prefers a reachable real executor, builds the web UI if
|
package/bridge/src/server.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// per-launch token + OS-keychain (roctinam/aiwg#1595).
|
|
8
8
|
import http from 'node:http';
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
-
import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat } from 'node:fs/promises';
|
|
10
|
+
import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
@@ -27,11 +27,14 @@ const ALLOW_MOCK_EXECUTOR = process.env.AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR === '1'
|
|
|
27
27
|
const AUTOSTART_EXECUTOR = process.env.AIWG_COCKPIT_AUTOSTART_EXECUTOR !== '0';
|
|
28
28
|
const EXECUTOR_COMMAND = process.env.AIWG_COCKPIT_EXECUTOR_COMMAND ?? '';
|
|
29
29
|
const RUNTIME_DIR = join(homedir(), '.aiwg', 'cockpit', 'runtime');
|
|
30
|
+
const auditDir = () => process.env.AIWG_COCKPIT_AUDIT_DIR || join(homedir(), '.aiwg', 'cockpit', 'audit');
|
|
31
|
+
const auditLog = () => join(auditDir(), 'events.jsonl');
|
|
30
32
|
// The built React app (apps/cockpit/web/dist). Served when present; falls back to the
|
|
31
33
|
// legacy vanilla page so the Bridge works even before a web build.
|
|
32
34
|
const WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
|
|
33
35
|
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' };
|
|
34
36
|
const CAPABILITY_TYPES = new Set(['skill', 'agent', 'command', 'rule', 'flow']);
|
|
37
|
+
const mcSessionsDir = () => join(process.cwd(), '.aiwg', 'ralph-external', 'mc', 'sessions');
|
|
35
38
|
|
|
36
39
|
/** Serve a static file from the built web app, sandboxed to WEB_DIST. Returns true if served. */
|
|
37
40
|
async function serveDistFile(res, relPath) {
|
|
@@ -86,13 +89,14 @@ async function writeRuntimeToken({ token, port, pid }) {
|
|
|
86
89
|
await mkdir(RUNTIME_DIR, { recursive: true, mode: 0o700 });
|
|
87
90
|
const file = join(RUNTIME_DIR, 'bridge.json');
|
|
88
91
|
const runtime = { token, port, pid, started_at: new Date().toISOString(), keychain_backed: false };
|
|
92
|
+
const strict = process.env.AIWG_COCKPIT_KEYCHAIN_STRICT === '1';
|
|
89
93
|
try {
|
|
90
94
|
runtime.token_ref = await storeCockpitToken(token, `bridge-${pid}`);
|
|
91
95
|
runtime.keychain_backed = true;
|
|
92
|
-
if (
|
|
96
|
+
if (strict) delete runtime.token;
|
|
93
97
|
} catch (e) {
|
|
94
98
|
runtime.keychain_error = String(e?.message ?? e);
|
|
95
|
-
if (process.env.AIWG_COCKPIT_REQUIRE_KEYCHAIN === '1') throw e;
|
|
99
|
+
if (strict || process.env.AIWG_COCKPIT_REQUIRE_KEYCHAIN === '1') throw e;
|
|
96
100
|
}
|
|
97
101
|
await writeFile(file, JSON.stringify(runtime, null, 2), { mode: 0o600 });
|
|
98
102
|
await chmod(file, 0o600);
|
|
@@ -201,13 +205,25 @@ function validateContribution(m, where) {
|
|
|
201
205
|
if (!a.inject || typeof a.inject.command !== 'string') fail(`action ${a.id}: inject.command (string) required`);
|
|
202
206
|
if (a.inject.target && !['focused', 'new'].includes(a.inject.target)) fail(`action ${a.id}: inject.target must be focused|new`);
|
|
203
207
|
}
|
|
204
|
-
for (const s of c.screens || []) {
|
|
208
|
+
for (const s of c.screens || []) {
|
|
209
|
+
if (!ID_RE.test(s.id || '')) fail(`screen.id invalid: ${s.id}`);
|
|
210
|
+
if (typeof s.title !== 'string') fail(`screen ${s.id}: title required`);
|
|
211
|
+
if (typeof s.source !== 'string') fail(`screen ${s.id}: source required`);
|
|
212
|
+
}
|
|
213
|
+
for (const w of c.workflows || []) {
|
|
214
|
+
if (!ID_RE.test(w.id || '')) fail(`workflow.id invalid: ${w.id}`);
|
|
215
|
+
if (typeof w.title !== 'string') fail(`workflow ${w.id}: title required`);
|
|
216
|
+
if (!Array.isArray(w.steps) || w.steps.length === 0) fail(`workflow ${w.id}: steps required`);
|
|
217
|
+
for (const step of w.steps) {
|
|
218
|
+
if (!step || typeof step !== 'object' || !ID_RE.test(step.action || '')) fail(`workflow ${w.id}: step.action invalid`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
205
221
|
for (const h of c.hooks || []) { if (typeof h.on !== 'string' || !ID_RE.test(h.action || '')) fail(`hook invalid: on=${h.on}`); }
|
|
206
222
|
return m;
|
|
207
223
|
}
|
|
208
224
|
/** Load + validate + merge all contribution manifests across the configured dirs. */
|
|
209
225
|
async function loadContributions() {
|
|
210
|
-
const sources = [], actions = [], screens = [], hooks = [];
|
|
226
|
+
const sources = [], actions = [], screens = [], hooks = [], workflows = [];
|
|
211
227
|
for (const dir of CONTRIB_DIRS) {
|
|
212
228
|
let entries = [];
|
|
213
229
|
try { entries = (await readdir(dir)).filter((f) => f.endsWith('.json') && f !== 'contribution.schema.json'); } catch { continue; }
|
|
@@ -215,11 +231,65 @@ async function loadContributions() {
|
|
|
215
231
|
const m = validateContribution(JSON.parse(await readFile(join(dir, file), 'utf8')), file);
|
|
216
232
|
sources.push({ id: m.id, version: m.version, title: m.title ?? m.id, file });
|
|
217
233
|
for (const a of m.contributes?.actions || []) actions.push({ ...a, source: m.id });
|
|
218
|
-
for (const s of m.contributes?.screens || []) screens.push({ ...s,
|
|
234
|
+
for (const s of m.contributes?.screens || []) screens.push({ ...s, contribution: m.id });
|
|
219
235
|
for (const h of m.contributes?.hooks || []) hooks.push({ ...h, source: m.id });
|
|
236
|
+
for (const w of m.contributes?.workflows || []) workflows.push({ ...w, source: m.id });
|
|
220
237
|
}
|
|
221
238
|
}
|
|
222
|
-
return { sources, actions, screens, hooks };
|
|
239
|
+
return { sources, actions, screens, hooks, workflows };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function safeIndexGraph(value) {
|
|
243
|
+
const graph = String(value ?? '').trim();
|
|
244
|
+
if (!graph) return '';
|
|
245
|
+
if (!ID_RE.test(graph)) throw new Error('graph must match [a-z0-9._-]{1,64}');
|
|
246
|
+
return graph;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function safeIndexLimit(value, fallback = 20) {
|
|
250
|
+
const n = Number(value ?? fallback);
|
|
251
|
+
if (!Number.isInteger(n) || n < 1 || n > 100) throw new Error('limit must be an integer from 1 to 100');
|
|
252
|
+
return n;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function getIndexStatus() {
|
|
256
|
+
return JSON.parse(await runAiwg(['index', 'status', '--json']));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function queryIndex(url) {
|
|
260
|
+
const q = (url.searchParams.get('q') || '').trim();
|
|
261
|
+
if (!q) return { status: 400, body: { error: 'q_required' } };
|
|
262
|
+
let limit;
|
|
263
|
+
try { limit = safeIndexLimit(url.searchParams.get('limit'), 20); }
|
|
264
|
+
catch (e) { return { status: 400, body: { error: 'invalid_limit', detail: String(e?.message ?? e) } }; }
|
|
265
|
+
const args = ['index', 'query', q, '--json', '--backend', 'local', '--limit', String(limit)];
|
|
266
|
+
try {
|
|
267
|
+
const graph = safeIndexGraph(url.searchParams.get('graph'));
|
|
268
|
+
if (graph) args.push('--graph', graph);
|
|
269
|
+
} catch (e) { return { status: 400, body: { error: 'invalid_graph', detail: String(e?.message ?? e) } }; }
|
|
270
|
+
for (const flag of ['type', 'phase', 'tags', 'path']) {
|
|
271
|
+
const value = (url.searchParams.get(flag) || '').trim();
|
|
272
|
+
if (value) args.push(`--${flag}`, value);
|
|
273
|
+
}
|
|
274
|
+
return { status: 200, body: JSON.parse(await runAiwg(args)) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function rebuildIndex(req) {
|
|
278
|
+
const parsed = await readJsonBody(req);
|
|
279
|
+
if (parsed.error) return { status: 400, body: { error: parsed.error } };
|
|
280
|
+
const body = parsed.body || {};
|
|
281
|
+
const args = ['index', 'build'];
|
|
282
|
+
try {
|
|
283
|
+
const graph = safeIndexGraph(body.graph);
|
|
284
|
+
if (graph) args.push('--graph', graph);
|
|
285
|
+
} catch (e) { return { status: 400, body: { error: 'invalid_graph', detail: String(e?.message ?? e) } }; }
|
|
286
|
+
if (body.all === true) args.push('--all');
|
|
287
|
+
if (body.force === true) args.push('--force');
|
|
288
|
+
const requested = await appendAudit('index.rebuild.requested', { graph: body.graph ?? null, all: body.all === true, force: body.force === true });
|
|
289
|
+
const output = await runAiwg(args);
|
|
290
|
+
const status = await getIndexStatus();
|
|
291
|
+
await appendAudit('index.rebuild.completed', { request_ts: requested.ts, graph: body.graph ?? null, all: body.all === true, force: body.force === true });
|
|
292
|
+
return { status: 200, body: { ok: true, command: `aiwg ${args.join(' ')}`, output, status } };
|
|
223
293
|
}
|
|
224
294
|
|
|
225
295
|
function json(res, status, body) {
|
|
@@ -227,6 +297,59 @@ function json(res, status, body) {
|
|
|
227
297
|
res.end(JSON.stringify(body));
|
|
228
298
|
}
|
|
229
299
|
|
|
300
|
+
function redactAuditValue(value) {
|
|
301
|
+
if (value === undefined || value === null) return value;
|
|
302
|
+
if (Array.isArray(value)) return value.map(redactAuditValue);
|
|
303
|
+
if (typeof value === 'object') {
|
|
304
|
+
const out = {};
|
|
305
|
+
for (const [k, v] of Object.entries(value)) {
|
|
306
|
+
if (/token|secret|password|credential|api[_-]?key|authorization|csrf/i.test(k)) out[k] = '[redacted]';
|
|
307
|
+
else out[k] = redactAuditValue(v);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
if (typeof value === 'string' && /(bearer\s+[a-z0-9._-]+|sk-[a-z0-9]|gh[pousr]_[a-z0-9])/i.test(value)) return '[redacted]';
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function appendAudit(event, fields = {}) {
|
|
316
|
+
const dir = auditDir();
|
|
317
|
+
const log = auditLog();
|
|
318
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
319
|
+
const entry = redactAuditValue({
|
|
320
|
+
event,
|
|
321
|
+
ts: new Date().toISOString(),
|
|
322
|
+
actor: 'operator',
|
|
323
|
+
surface: 'cockpit-bridge',
|
|
324
|
+
...fields,
|
|
325
|
+
});
|
|
326
|
+
await appendFile(log, JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
327
|
+
await chmod(log, 0o600).catch(() => undefined);
|
|
328
|
+
return entry;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function readAudit({ limit = 50 } = {}) {
|
|
332
|
+
try {
|
|
333
|
+
const raw = await readFile(auditLog(), 'utf8');
|
|
334
|
+
return raw.trim().split(/\n+/).filter(Boolean).slice(-limit).map((line) => {
|
|
335
|
+
try { return JSON.parse(line); } catch { return { event: 'unparsed', line }; }
|
|
336
|
+
});
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function readJsonBody(req) {
|
|
343
|
+
const chunks = [];
|
|
344
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
345
|
+
const rawBody = Buffer.concat(chunks).toString('utf8') || '{}';
|
|
346
|
+
try {
|
|
347
|
+
return { body: JSON.parse(rawBody) };
|
|
348
|
+
} catch {
|
|
349
|
+
return { error: 'invalid_json' };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
230
353
|
/** Forward a control-plane call to the executor admin surface, relaying status + body. */
|
|
231
354
|
async function proxy(res, method, target) {
|
|
232
355
|
const r = await fetch(target, { method });
|
|
@@ -917,6 +1040,185 @@ async function getApprovals(executorUrl, status) {
|
|
|
917
1040
|
};
|
|
918
1041
|
}
|
|
919
1042
|
|
|
1043
|
+
const TERMINAL_MISSION_STATES = new Set(['done', 'completed', 'complete', 'failed', 'aborted', 'canceled', 'cancelled', 'rejected']);
|
|
1044
|
+
|
|
1045
|
+
function normalizeMissionStatus(status) {
|
|
1046
|
+
const value = String(status ?? 'unknown').toLowerCase();
|
|
1047
|
+
if (value === 'done' || value === 'complete') return 'completed';
|
|
1048
|
+
if (value === 'cancelled' || value === 'canceled') return 'aborted';
|
|
1049
|
+
if (['queued', 'running', 'paused', 'completed', 'failed', 'aborted', 'input-required', 'awaiting-approval', 'unknown'].includes(value)) return value;
|
|
1050
|
+
return value;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function missionSummary(mission) {
|
|
1054
|
+
const status = normalizeMissionStatus(mission.status);
|
|
1055
|
+
return {
|
|
1056
|
+
id: String(mission.id ?? mission.mission_id ?? mission.missionId ?? ''),
|
|
1057
|
+
title: mission.objective ?? mission.goal ?? mission.task ?? mission.title ?? 'Untitled mission',
|
|
1058
|
+
completion: mission.completion ?? mission.completionCriterion ?? mission.completion_criterion,
|
|
1059
|
+
status,
|
|
1060
|
+
loop: mission.loop ?? mission.iteration ?? mission.currentIteration ?? 0,
|
|
1061
|
+
max_iterations: mission.maxIterations ?? mission.max_iterations ?? mission.maxIterations ?? 0,
|
|
1062
|
+
priority: mission.priority ?? 'normal',
|
|
1063
|
+
mode: mission.mode ?? 'direct',
|
|
1064
|
+
target_agent: mission.targetAgent ?? mission.target_agent,
|
|
1065
|
+
ralph_loop_id: mission.ralphLoopId ?? mission.ralph_loop_id,
|
|
1066
|
+
ralph_pid: mission.ralphPid ?? mission.ralph_pid,
|
|
1067
|
+
started_at: mission.startedAt ?? mission.started_at,
|
|
1068
|
+
completed_at: mission.completedAt ?? mission.completed_at,
|
|
1069
|
+
error: mission.error,
|
|
1070
|
+
terminal: TERMINAL_MISSION_STATES.has(status),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function readMcAudit(sessionId) {
|
|
1075
|
+
const logPath = join(mcSessionsDir(), sessionId, 'log.jsonl');
|
|
1076
|
+
try {
|
|
1077
|
+
const raw = await readFile(logPath, 'utf8');
|
|
1078
|
+
return raw.trim().split(/\n+/).filter(Boolean).map((line) => {
|
|
1079
|
+
try { return JSON.parse(line); } catch { return { event: 'unparsed', line }; }
|
|
1080
|
+
});
|
|
1081
|
+
} catch {
|
|
1082
|
+
return [];
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function readMcSessions() {
|
|
1087
|
+
let entries = [];
|
|
1088
|
+
const sessionsDir = mcSessionsDir();
|
|
1089
|
+
try { entries = await readdir(sessionsDir, { withFileTypes: true }); } catch { return []; }
|
|
1090
|
+
const sessions = [];
|
|
1091
|
+
for (const entry of entries) {
|
|
1092
|
+
if (!entry.isDirectory()) continue;
|
|
1093
|
+
try {
|
|
1094
|
+
const raw = await readFile(join(sessionsDir, entry.name, 'session.json'), 'utf8');
|
|
1095
|
+
const session = JSON.parse(raw);
|
|
1096
|
+
const audit = await readMcAudit(entry.name);
|
|
1097
|
+
sessions.push({
|
|
1098
|
+
id: String(session.id ?? entry.name),
|
|
1099
|
+
name: session.name ?? entry.name,
|
|
1100
|
+
state: session.state ?? 'unknown',
|
|
1101
|
+
source: 'aiwg-mc',
|
|
1102
|
+
created_at: session.createdAt ?? session.created_at,
|
|
1103
|
+
updated_at: session.updatedAt ?? session.updated_at,
|
|
1104
|
+
max_missions: session.maxMissions ?? session.max_missions,
|
|
1105
|
+
audit_count: audit.length,
|
|
1106
|
+
audit_tail: audit.slice(-8),
|
|
1107
|
+
missions: (session.missions ?? []).map((m) => ({ ...missionSummary(m), session_id: session.id ?? entry.name, source: 'aiwg-mc' })),
|
|
1108
|
+
});
|
|
1109
|
+
} catch {
|
|
1110
|
+
// Ignore malformed or half-written sessions; the next refresh will retry.
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
sessions.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')));
|
|
1114
|
+
return sessions;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function taskMissionSession(executorUrl) {
|
|
1118
|
+
const running = await getRunning(executorUrl).catch(() => ({ running: [] }));
|
|
1119
|
+
const approvals = await getApprovals(executorUrl, 'pending').catch(() => ({ approvals: [] }));
|
|
1120
|
+
const taskMissions = [
|
|
1121
|
+
...(running.running ?? []).map((t) => ({
|
|
1122
|
+
id: `${t.instance_id}::${t.task_id}`,
|
|
1123
|
+
session_id: 'executor-live',
|
|
1124
|
+
source: 'executor-task',
|
|
1125
|
+
title: `Task ${t.task_id}`,
|
|
1126
|
+
status: normalizeMissionStatus(t.state),
|
|
1127
|
+
instance_id: t.instance_id,
|
|
1128
|
+
task_id: t.task_id,
|
|
1129
|
+
tenant: t.tenant,
|
|
1130
|
+
runtime_posture: t.runtime_posture,
|
|
1131
|
+
transport: t.transport,
|
|
1132
|
+
terminal: false,
|
|
1133
|
+
})),
|
|
1134
|
+
...(approvals.approvals ?? []).map((a) => ({
|
|
1135
|
+
id: a.id,
|
|
1136
|
+
session_id: 'executor-live',
|
|
1137
|
+
source: 'hitl-approval',
|
|
1138
|
+
title: a.prompt,
|
|
1139
|
+
status: 'awaiting-approval',
|
|
1140
|
+
instance_id: a.instance_id,
|
|
1141
|
+
task_id: a.task_id,
|
|
1142
|
+
tenant: a.tenant,
|
|
1143
|
+
risk: a.risk,
|
|
1144
|
+
terminal: false,
|
|
1145
|
+
})),
|
|
1146
|
+
];
|
|
1147
|
+
if (!taskMissions.length) return null;
|
|
1148
|
+
return {
|
|
1149
|
+
id: 'executor-live',
|
|
1150
|
+
name: 'Executor live tasks',
|
|
1151
|
+
state: 'active',
|
|
1152
|
+
source: 'agentic-sandbox',
|
|
1153
|
+
updated_at: new Date().toISOString(),
|
|
1154
|
+
audit_count: 0,
|
|
1155
|
+
audit_tail: [],
|
|
1156
|
+
missions: taskMissions,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function getMissions(executorUrl) {
|
|
1161
|
+
const sessions = await readMcSessions();
|
|
1162
|
+
const live = await taskMissionSession(executorUrl);
|
|
1163
|
+
if (live) sessions.unshift(live);
|
|
1164
|
+
const missions = sessions.flatMap((s) => s.missions);
|
|
1165
|
+
return {
|
|
1166
|
+
source: 'aiwg-mc + agentic-sandbox',
|
|
1167
|
+
fetched_at: new Date().toISOString(),
|
|
1168
|
+
count: missions.length,
|
|
1169
|
+
sessions,
|
|
1170
|
+
missions,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function getSessionEventRows(executorUrl, instances) {
|
|
1175
|
+
const rows = [];
|
|
1176
|
+
await Promise.all((instances ?? []).map(async (inst) => {
|
|
1177
|
+
let sessions;
|
|
1178
|
+
try { sessions = (await getSessions(executorUrl, inst.id)).sessions; } catch { return; }
|
|
1179
|
+
for (const session of sessions) {
|
|
1180
|
+
rows.push({
|
|
1181
|
+
id: session.id,
|
|
1182
|
+
instance_id: inst.id,
|
|
1183
|
+
agent_id: session.agent_id,
|
|
1184
|
+
state: session.state ?? session.status ?? session.session_state ?? 'available',
|
|
1185
|
+
role_policy: session.role_policy,
|
|
1186
|
+
backend: session.backend ?? session.session_backend,
|
|
1187
|
+
mode: session.mode ?? session.session_class,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
}));
|
|
1191
|
+
return rows;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function getEventSnapshot(executorUrl) {
|
|
1195
|
+
const inventory = await getInventory(executorUrl).catch(() => ({ instances: [] }));
|
|
1196
|
+
const [running, approvals, missions, sessions] = await Promise.all([
|
|
1197
|
+
getRunning(executorUrl).catch(() => ({ running: [] })),
|
|
1198
|
+
getApprovals(executorUrl, 'pending').catch(() => ({ approvals: [] })),
|
|
1199
|
+
getMissions(executorUrl).catch(() => ({ missions: [] })),
|
|
1200
|
+
getSessionEventRows(executorUrl, inventory.instances).catch(() => []),
|
|
1201
|
+
]);
|
|
1202
|
+
const ts = new Date().toISOString();
|
|
1203
|
+
const events = [];
|
|
1204
|
+
for (const inst of inventory.instances ?? []) {
|
|
1205
|
+
events.push({ id: `instance:${inst.id}`, type: 'inventory.instance', source: 'agentic-sandbox', subject: inst.id, state: inst.state, ts, ref: { instance_id: inst.id } });
|
|
1206
|
+
}
|
|
1207
|
+
for (const task of running.running ?? []) {
|
|
1208
|
+
events.push({ id: `task:${task.instance_id}:${task.task_id}`, type: 'task.lifecycle', source: 'a2a', subject: task.task_id, state: task.state, ts, ref: { instance_id: task.instance_id, task_id: task.task_id } });
|
|
1209
|
+
}
|
|
1210
|
+
for (const approval of approvals.approvals ?? []) {
|
|
1211
|
+
events.push({ id: `approval:${approval.id}`, type: 'hitl.approval', source: 'a2a', subject: approval.task_id ?? approval.id, state: approval.status, severity: approval.risk, ts, ref: { instance_id: approval.instance_id, approval_id: approval.id } });
|
|
1212
|
+
}
|
|
1213
|
+
for (const session of sessions ?? []) {
|
|
1214
|
+
events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id, backend: session.backend, mode: session.mode, role_policy: session.role_policy } });
|
|
1215
|
+
}
|
|
1216
|
+
for (const mission of missions.missions ?? []) {
|
|
1217
|
+
events.push({ id: `mission:${mission.id}`, type: 'mission.lifecycle', source: mission.source ?? 'aiwg-mc', subject: mission.id, state: mission.status, ts, ref: { session_id: mission.session_id, mission_id: mission.id, ralph_loop_id: mission.ralph_loop_id } });
|
|
1218
|
+
}
|
|
1219
|
+
return { source: 'cockpit.unified-event-model/v1', fetched_at: ts, count: events.length, events };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
920
1222
|
async function respondApproval(executorUrl, approvalId, decision) {
|
|
921
1223
|
if (!['approve', 'deny'].includes(decision)) return { status: 400, body: { error: 'decision must be approve|deny' } };
|
|
922
1224
|
const [instanceId, taskId] = String(approvalId).split('::');
|
|
@@ -1097,18 +1399,35 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1097
1399
|
if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
|
|
1098
1400
|
if (url.pathname === '/api/executor/capabilities') return json(res, 200, await getExecutorCapabilities(upstreamUrl));
|
|
1099
1401
|
if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
|
|
1402
|
+
if (url.pathname === '/api/missions') return json(res, 200, await getMissions(upstreamUrl));
|
|
1403
|
+
if (url.pathname === '/api/events/snapshot') return json(res, 200, await getEventSnapshot(upstreamUrl));
|
|
1100
1404
|
if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
|
|
1405
|
+
if (url.pathname === '/api/index/status' && req.method === 'GET') return json(res, 200, await getIndexStatus());
|
|
1406
|
+
if (url.pathname === '/api/index/query' && req.method === 'GET') {
|
|
1407
|
+
const result = await queryIndex(url);
|
|
1408
|
+
return json(res, result.status, result.body);
|
|
1409
|
+
}
|
|
1410
|
+
if (url.pathname === '/api/index/rebuild' && req.method === 'POST') {
|
|
1411
|
+
const result = await rebuildIndex(req);
|
|
1412
|
+
return json(res, result.status, result.body);
|
|
1413
|
+
}
|
|
1414
|
+
if (url.pathname === '/api/audit' && req.method === 'GET') {
|
|
1415
|
+
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
|
|
1416
|
+
return json(res, 200, { source: 'cockpit-bridge-audit/v1', audit: await readAudit({ limit }) });
|
|
1417
|
+
}
|
|
1418
|
+
if (url.pathname === '/api/audit/intent' && req.method === 'POST') {
|
|
1419
|
+
const parsed = await readJsonBody(req);
|
|
1420
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
1421
|
+
const body = parsed.body || {};
|
|
1422
|
+
const event = typeof body.event === 'string' && body.event.trim() ? body.event.trim() : 'operator.intent';
|
|
1423
|
+
const entry = await appendAudit(event, { detail: body.detail ?? body });
|
|
1424
|
+
return json(res, 201, entry);
|
|
1425
|
+
}
|
|
1101
1426
|
let m;
|
|
1102
1427
|
if (url.pathname === '/api/instances' && req.method === 'POST') {
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
const
|
|
1106
|
-
let payload;
|
|
1107
|
-
try {
|
|
1108
|
-
payload = JSON.parse(rawBody);
|
|
1109
|
-
} catch {
|
|
1110
|
-
return json(res, 400, { error: 'invalid_json' });
|
|
1111
|
-
}
|
|
1428
|
+
const parsed = await readJsonBody(req);
|
|
1429
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
1430
|
+
const payload = parsed.body;
|
|
1112
1431
|
if (payload.runtime === 'qemu') {
|
|
1113
1432
|
const sshKey = expandHome(String(payload.ssh_key ?? payload.sshKey ?? '').trim()) || defaultSshPublicKey();
|
|
1114
1433
|
if (!sshKey) {
|
|
@@ -1128,14 +1447,22 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1128
1447
|
payload.ssh_key = sshKey;
|
|
1129
1448
|
}
|
|
1130
1449
|
const requestBody = JSON.stringify(payload);
|
|
1131
|
-
|
|
1450
|
+
const before = await appendAudit('instance.launch.requested', {
|
|
1451
|
+
runtime: payload.runtime,
|
|
1452
|
+
name: payload.name,
|
|
1453
|
+
loadout: payload.loadout,
|
|
1454
|
+
start: payload.start,
|
|
1455
|
+
});
|
|
1456
|
+
const result = await fetchJsonFirst([
|
|
1132
1457
|
{
|
|
1133
1458
|
target: `${upstreamUrl}/api/v2/admin/instances`,
|
|
1134
1459
|
method: 'POST',
|
|
1135
1460
|
headers: { 'content-type': 'application/json' },
|
|
1136
1461
|
body: requestBody,
|
|
1137
1462
|
},
|
|
1138
|
-
]);
|
|
1463
|
+
]).catch((err) => ({ status: 502, body: { error: 'bridge_upstream_error', message: String(err?.message ?? err) } }));
|
|
1464
|
+
await appendAudit('instance.launch.result', { request_ts: before.ts, status: result.status, result: result.body });
|
|
1465
|
+
return json(res, result.status, result.body);
|
|
1139
1466
|
}
|
|
1140
1467
|
if ((m = url.pathname.match(/^\/api\/operations\/([^/]+)$/)) && req.method === 'GET') {
|
|
1141
1468
|
return proxyFirst(res, [
|
|
@@ -1170,7 +1497,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1170
1497
|
args.push('--type', types.join(','));
|
|
1171
1498
|
}
|
|
1172
1499
|
const data = JSON.parse(await runAiwg(args));
|
|
1173
|
-
data.results = (data.results || []).map((r) => ({
|
|
1500
|
+
data.results = (data.results || []).map((r) => ({
|
|
1501
|
+
...r,
|
|
1502
|
+
name: r.name || (r.path ? deriveName(r.path) : ''),
|
|
1503
|
+
}));
|
|
1174
1504
|
return json(res, 200, data);
|
|
1175
1505
|
}
|
|
1176
1506
|
if (url.pathname === '/api/show') {
|
|
@@ -1279,27 +1609,37 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1279
1609
|
}
|
|
1280
1610
|
// Same as the list path (#1671): the attach segment must be the instance
|
|
1281
1611
|
// id the executor's pty-ws route accepts, not the resolved agent name.
|
|
1612
|
+
await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId });
|
|
1282
1613
|
return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
|
|
1283
1614
|
}
|
|
1284
1615
|
|
|
1285
1616
|
// --- management surface (UC-012): lifecycle + task cancel ---
|
|
1286
|
-
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop)$/)) && req.method === 'POST')
|
|
1287
|
-
|
|
1617
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop)$/)) && req.method === 'POST') {
|
|
1618
|
+
const result = await fetchJsonFirst([
|
|
1288
1619
|
`${upstreamUrl}/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
|
|
1289
1620
|
`${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
|
|
1290
|
-
], { method: 'POST' });
|
|
1621
|
+
], { method: 'POST' }).catch((err) => ({ status: 502, body: { error: 'bridge_upstream_error', message: String(err?.message ?? err) } }));
|
|
1622
|
+
await appendAudit('instance.lifecycle.requested', { instance_id: decodeURIComponent(m[1]), action: m[2], status: result.status, result: result.body });
|
|
1623
|
+
return json(res, result.status, result.body);
|
|
1624
|
+
}
|
|
1291
1625
|
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)$/)) && req.method === 'DELETE') {
|
|
1292
1626
|
const { status, body } = await destroyInstance(upstreamUrl, decodeURIComponent(m[1]));
|
|
1627
|
+
await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
|
|
1293
1628
|
return json(res, status, body);
|
|
1294
1629
|
}
|
|
1295
|
-
if ((m = url.pathname.match(/^\/api\/tasks\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST')
|
|
1630
|
+
if ((m = url.pathname.match(/^\/api\/tasks\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST') {
|
|
1631
|
+
await appendAudit('task.cancel.requested', { instance_id: decodeURIComponent(m[1]), task_id: decodeURIComponent(m[2]) });
|
|
1296
1632
|
return proxy(res, 'POST', `${upstreamUrl}/agents/${encodeURIComponent(m[1])}/tasks/${encodeURIComponent(m[2])}:cancel`);
|
|
1633
|
+
}
|
|
1297
1634
|
|
|
1298
1635
|
// --- approval inbox (UC-009) + cost (UC-010) ---
|
|
1299
1636
|
if (url.pathname === '/api/approvals' && req.method === 'GET')
|
|
1300
1637
|
return json(res, 200, await getApprovals(upstreamUrl, url.searchParams.get('status') || 'pending'));
|
|
1301
1638
|
if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST') {
|
|
1302
|
-
const
|
|
1639
|
+
const approvalId = decodeURIComponent(m[1]);
|
|
1640
|
+
const decision = url.searchParams.get('decision') || '';
|
|
1641
|
+
const { status, body } = await respondApproval(upstreamUrl, approvalId, decision);
|
|
1642
|
+
await appendAudit('approval.response.submitted', { approval_id: approvalId, decision, status, result: body });
|
|
1303
1643
|
return json(res, status, body);
|
|
1304
1644
|
}
|
|
1305
1645
|
if (url.pathname === '/api/cost' && req.method === 'GET')
|
|
@@ -1363,8 +1703,13 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
1363
1703
|
await ensureExecutor(EXECUTOR_URL);
|
|
1364
1704
|
const server = createBridge();
|
|
1365
1705
|
server.listen(port, '127.0.0.1', async () => {
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1706
|
+
try {
|
|
1707
|
+
const file = await writeRuntimeToken({ token: server.cockpitToken, port, pid: process.pid });
|
|
1708
|
+
console.log(`[cockpit-bridge] http://127.0.0.1:${port} (executor ${EXECUTOR_URL})`);
|
|
1709
|
+
console.log(` token written ${file} (mode 600) — open the URL in a browser or attach a shell`);
|
|
1710
|
+
} catch (err) {
|
|
1711
|
+
console.error(`[cockpit-bridge] failed to persist runtime token: ${String(err?.message ?? err)}`);
|
|
1712
|
+
server.close(() => process.exit(1));
|
|
1713
|
+
}
|
|
1369
1714
|
});
|
|
1370
1715
|
}
|
package/bridge/src/smoke.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// End-to-end data-path smoke: executor fixture (admin) -> Bridge (/api/inventory) -> served screen.
|
|
2
2
|
// Self-contained (own ports); no deps. Exits non-zero on failure.
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { createExecutor } from '../../mock-executor/src/server.mjs';
|
|
5
6
|
import { createBridge } from './server.mjs';
|
|
6
7
|
|
|
@@ -74,9 +75,13 @@ try {
|
|
|
74
75
|
assert.match(shown.body, /name:\s*flow-deploy-to-production/, 'show returns the skill body');
|
|
75
76
|
assert.equal((await f("/api/capabilities")).status, 400, 'capabilities requires q');
|
|
76
77
|
// show by discovered PATH — deterministic, sidesteps ambiguous same-named artifacts (#1643)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
if (hit.path) {
|
|
79
|
+
const shownByPath = await (await f(`/api/show?path=${encodeURIComponent(hit.path)}`)).json();
|
|
80
|
+
assert.match(shownByPath.body, /name:\s*flow-deploy-to-production/, 'show-by-path returns the body');
|
|
81
|
+
assert.equal(shownByPath.path, hit.path, 'show-by-path echoes the resolved path');
|
|
82
|
+
} else {
|
|
83
|
+
assert.ok(hit.id, 'pathless discover result carries a stable id');
|
|
84
|
+
}
|
|
80
85
|
// a missing artifact is a 4xx, never a 502 (ambiguous/not-found map to operator-correctable input)
|
|
81
86
|
assert.equal((await f('/api/show?type=agent&name=__definitely_not_a_real_artifact__')).status, 404, 'unknown artifact -> 404 not 502');
|
|
82
87
|
// a path outside the AIWG corpus is refused (no traversal)
|
|
@@ -123,7 +128,8 @@ try {
|
|
|
123
128
|
|
|
124
129
|
// user asset library: clone a catalog asset into the library, list it, delete it.
|
|
125
130
|
// (AIWG source is read-only — clone copies into ~/.aiwg/cockpit/library, never the reverse.)
|
|
126
|
-
const
|
|
131
|
+
const libraryPath = hit.path || fileURLToPath(new URL('../../../../agentic/code/frameworks/sdlc-complete/skills/flow-deploy-to-production/SKILL.md', import.meta.url));
|
|
132
|
+
const cloneRes = await f(`/api/library/clone?type=${encodeURIComponent(hit.type)}&name=${encodeURIComponent(hit.name)}&path=${encodeURIComponent(libraryPath)}`, { method: 'POST' });
|
|
127
133
|
assert.ok([201, 400].includes(cloneRes.status), 'clone returns 201 (new) or 400 (already present)');
|
|
128
134
|
const lib1 = await (await f('/api/library')).json();
|
|
129
135
|
assert.ok(lib1.library.some((a) => a.name === hit.name), 'cloned asset appears in the user library');
|
package/contrib/aiwg-core.json
CHANGED
|
@@ -2,14 +2,36 @@
|
|
|
2
2
|
"$schema": "./contribution.schema.json",
|
|
3
3
|
"id": "aiwg-core",
|
|
4
4
|
"version": "1.0.0",
|
|
5
|
-
"title": "AIWG Core
|
|
5
|
+
"title": "AIWG Core Contributions",
|
|
6
6
|
"contributes": {
|
|
7
7
|
"actions": [
|
|
8
8
|
{ "id": "audit-issues", "title": "Audit Issues", "icon": "🔍", "group": "issues", "inject": { "command": "/issue-audit", "target": "focused" } },
|
|
9
9
|
{ "id": "address-issues", "title": "Address Issues", "icon": "🛠️", "group": "issues", "inject": { "command": "/address-issues", "target": "focused", "needs_args": true, "args_hint": "issue numbers, space-separated" } },
|
|
10
10
|
{ "id": "doctor", "title": "Doctor", "icon": "🩺", "group": "maintenance", "inject": { "command": "/aiwg-doctor", "target": "focused" } }
|
|
11
11
|
],
|
|
12
|
-
"screens": [
|
|
12
|
+
"screens": [
|
|
13
|
+
{ "id": "index-live", "title": "Live Index", "source": "cockpit://index/live" },
|
|
14
|
+
{ "id": "issue-workbench", "title": "Issue Workbench", "source": "cockpit://issues/workbench" }
|
|
15
|
+
],
|
|
16
|
+
"workflows": [
|
|
17
|
+
{
|
|
18
|
+
"id": "issue-resolution",
|
|
19
|
+
"title": "Issue Resolution",
|
|
20
|
+
"description": "Audit selected issues, then dispatch the address-issues loop.",
|
|
21
|
+
"steps": [
|
|
22
|
+
{ "action": "audit-issues", "label": "Audit" },
|
|
23
|
+
{ "action": "address-issues", "label": "Address" }
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "maintenance-check",
|
|
28
|
+
"title": "Maintenance Check",
|
|
29
|
+
"description": "Run the AIWG doctor through an attached agentic session.",
|
|
30
|
+
"steps": [
|
|
31
|
+
{ "action": "doctor", "label": "Doctor" }
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
],
|
|
13
35
|
"hooks": []
|
|
14
36
|
}
|
|
15
37
|
}
|