@aiwg/cockpit 2026.8.3 → 2026.8.4
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/bridge/src/server.mjs +198 -16
- package/contrib/contribution.schema.json +2 -2
- package/package.json +1 -1
- package/shell-core/keychain.mjs +82 -63
- package/web/dist/assets/index-DNlFgn6L.js +312 -0
- package/web/dist/index.html +1 -1
- package/web/src/App.test.tsx +18 -0
- package/web/src/App.tsx +1 -1
- package/web/src/components/Actions.tsx +1 -0
- package/web/src/components/CapabilitySearch.tsx +5 -4
- package/web/src/components/Explore.tsx +45 -5
- package/web/src/components/Missions.tsx +89 -4
- package/web/src/types.ts +3 -3
- package/web/dist/assets/index-Dj3dFeFT.js +0 -312
package/bridge/src/server.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import http from 'node:http';
|
|
9
9
|
import https from 'node:https';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
|
-
import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
|
|
11
|
+
import { readFile, mkdir, writeFile, rename, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
|
|
12
12
|
import { existsSync, realpathSync } from 'node:fs';
|
|
13
13
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
14
14
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
@@ -42,7 +42,13 @@ const auditLog = () => join(auditDir(), 'events.jsonl');
|
|
|
42
42
|
// legacy vanilla page so the Bridge works even before a web build.
|
|
43
43
|
const WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
|
|
44
44
|
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' };
|
|
45
|
-
|
|
45
|
+
// Discovery indexes more than the four executable provider artifacts. Keep the
|
|
46
|
+
// Bridge filter aligned with the complete corpus so Explore does not hide
|
|
47
|
+
// extension and documentation surfaces (#1592).
|
|
48
|
+
const CAPABILITY_TYPES = new Set([
|
|
49
|
+
'skill', 'agent', 'command', 'rule', 'flow', 'behavior', 'hook', 'template',
|
|
50
|
+
'tool', 'addon', 'framework', 'extension', 'plugin', 'provider', 'document',
|
|
51
|
+
]);
|
|
46
52
|
const mcSessionsDir = () => join(process.cwd(), '.aiwg', 'ralph-external', 'mc', 'sessions');
|
|
47
53
|
const executorRequestContext = new AsyncLocalStorage();
|
|
48
54
|
|
|
@@ -177,13 +183,88 @@ function spawnCollect(cmd, args) {
|
|
|
177
183
|
p.stdout.on('data', (d) => (out += d));
|
|
178
184
|
p.stderr.on('data', (d) => (err += d));
|
|
179
185
|
p.once('error', reject);
|
|
180
|
-
p.once('close', (code) =>
|
|
186
|
+
p.once('close', (code) => {
|
|
187
|
+
if (code === 0) return resolve(out);
|
|
188
|
+
const failure = new Error(err.trim() || `aiwg exit ${code}`);
|
|
189
|
+
failure.exitCode = code;
|
|
190
|
+
failure.stdout = out;
|
|
191
|
+
reject(failure);
|
|
192
|
+
});
|
|
181
193
|
});
|
|
182
194
|
}
|
|
183
195
|
async function runAiwg(args) {
|
|
184
196
|
try { return await spawnCollect('aiwg', args); }
|
|
185
197
|
catch (e) { if (e && e.code === 'ENOENT') return spawnCollect(process.execPath, [REPO_BIN, ...args]); throw e; }
|
|
186
198
|
}
|
|
199
|
+
|
|
200
|
+
const MISSION_CONTROL_ID_RE = /^[a-zA-Z0-9._-]+$/;
|
|
201
|
+
async function controlMission({ action, sessionId, missionId, expectedUpdatedAt, requestId }) {
|
|
202
|
+
if (!['pause', 'resume', 'cancel'].includes(action)) throw Object.assign(new Error('unsupported mission control action'), { status: 400 });
|
|
203
|
+
if (!MISSION_CONTROL_ID_RE.test(sessionId) || (missionId && !MISSION_CONTROL_ID_RE.test(missionId))) {
|
|
204
|
+
throw Object.assign(new Error('invalid Mission control identifier'), { status: 400 });
|
|
205
|
+
}
|
|
206
|
+
const args = ['mc', action, sessionId];
|
|
207
|
+
if (action === 'cancel') {
|
|
208
|
+
if (!missionId) throw Object.assign(new Error('mission id required'), { status: 400 });
|
|
209
|
+
args.push(missionId);
|
|
210
|
+
}
|
|
211
|
+
if (expectedUpdatedAt) args.push('--expected-updated-at', String(expectedUpdatedAt));
|
|
212
|
+
if (requestId) args.push('--request-id', String(requestId));
|
|
213
|
+
await appendAudit('mission.control.requested', {
|
|
214
|
+
action,
|
|
215
|
+
session_id: sessionId,
|
|
216
|
+
mission_id: missionId ?? null,
|
|
217
|
+
expected_updated_at: expectedUpdatedAt ?? null,
|
|
218
|
+
request_id: requestId ?? null,
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
await runAiwg(args);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message = String(error?.message ?? error);
|
|
224
|
+
const status = error?.exitCode === 3 || /mission_conflict/.test(message) ? 409 : 422;
|
|
225
|
+
await appendAudit('mission.control.rejected', { action, session_id: sessionId, mission_id: missionId ?? null, request_id: requestId ?? null, status, reason: message });
|
|
226
|
+
throw Object.assign(new Error(message), { status });
|
|
227
|
+
}
|
|
228
|
+
await appendAudit('mission.control.completed', { action, session_id: sessionId, mission_id: missionId ?? null, request_id: requestId ?? null });
|
|
229
|
+
return { ok: true, action, session_id: sessionId, mission_id: missionId ?? null, request_id: requestId ?? null };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function dispatchMission(body, upstreamUrl) {
|
|
233
|
+
const sessionId = String(body?.session_id ?? '');
|
|
234
|
+
const objective = String(body?.objective ?? '').trim();
|
|
235
|
+
const completion = String(body?.completion ?? '').trim();
|
|
236
|
+
const requestId = String(body?.request_id ?? randomBytes(16).toString('hex'));
|
|
237
|
+
if (!MISSION_CONTROL_ID_RE.test(sessionId)) throw Object.assign(new Error('invalid Mission control session id'), { status: 400 });
|
|
238
|
+
if (!objective || objective.length > 4096) throw Object.assign(new Error('objective is required and must be at most 4096 characters'), { status: 400 });
|
|
239
|
+
if (completion.length > 4096) throw Object.assign(new Error('completion must be at most 4096 characters'), { status: 400 });
|
|
240
|
+
if (!MISSION_CONTROL_ID_RE.test(requestId)) throw Object.assign(new Error('request_id must contain only letters, digits, dot, underscore, or hyphen'), { status: 400 });
|
|
241
|
+
const args = ['mc', 'dispatch', sessionId, objective, '--request-id', requestId];
|
|
242
|
+
if (completion) args.push('--completion', completion);
|
|
243
|
+
if (body?.priority) args.push('--priority', String(body.priority));
|
|
244
|
+
if (body?.expected_updated_at) args.push('--expected-updated-at', String(body.expected_updated_at));
|
|
245
|
+
if (body?.max_iterations !== undefined) {
|
|
246
|
+
const maxIterations = Number(body.max_iterations);
|
|
247
|
+
if (!Number.isInteger(maxIterations) || maxIterations < 1 || maxIterations > 10_000) {
|
|
248
|
+
throw Object.assign(new Error('max_iterations must be an integer from 1 to 10000'), { status: 400 });
|
|
249
|
+
}
|
|
250
|
+
args.push('--max-iterations', String(maxIterations));
|
|
251
|
+
}
|
|
252
|
+
await appendAudit('mission.dispatch.requested', { session_id: sessionId, request_id: requestId, objective_digest: `sha256:${createHash('sha256').update(objective).digest('hex')}` });
|
|
253
|
+
try {
|
|
254
|
+
await runAiwg(args);
|
|
255
|
+
if (body?.run === true) {
|
|
256
|
+
await runAiwg(['mc', 'run', sessionId, ...(body?.accept_cost === true ? ['--accept-cost'] : [])]);
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const message = String(error?.message ?? error);
|
|
260
|
+
const status = error?.exitCode === 3 || /mission_conflict/.test(message) ? 409 : 422;
|
|
261
|
+
await appendAudit('mission.dispatch.rejected', { session_id: sessionId, request_id: requestId, status, reason: message });
|
|
262
|
+
throw Object.assign(new Error(message), { status });
|
|
263
|
+
}
|
|
264
|
+
const missionId = `m-${createHash('sha256').update(requestId).digest('hex').slice(0, 16)}`;
|
|
265
|
+
await appendAudit('mission.dispatch.completed', { session_id: sessionId, mission_id: missionId, request_id: requestId, run: body?.run === true });
|
|
266
|
+
return { ok: true, session_id: sessionId, mission_id: missionId, request_id: requestId, projection: await getMissions(upstreamUrl) };
|
|
267
|
+
}
|
|
187
268
|
// --- user asset library (#1591/#1593): the operator's OWN copied/cloned/imported
|
|
188
269
|
// assets, on disk under ~/.aiwg/cockpit/library. AIWG install files are NEVER written
|
|
189
270
|
// (clone reads the catalog read-only, writes only into the library). ---
|
|
@@ -256,23 +337,32 @@ function resolveCorpusPath(p) {
|
|
|
256
337
|
// --- UI contribution model (#1591): declarative screens/actions/event-hooks ---
|
|
257
338
|
const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
258
339
|
/** Validate one contribution manifest. Throws with a precise message on bad shape. */
|
|
259
|
-
function validateContribution(m, where) {
|
|
340
|
+
function validateContribution(m, where, { firstParty = false } = {}) {
|
|
260
341
|
const fail = (msg) => { throw new Error(`${where}: ${msg}`); };
|
|
261
342
|
if (!m || typeof m !== 'object') fail('manifest must be an object');
|
|
262
343
|
if (!ID_RE.test(m.id || '')) fail('id must match [a-z0-9._-]{1,64}');
|
|
263
|
-
if (typeof m.version !== 'string') fail('version
|
|
344
|
+
if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(m.version)) fail('version must be semantic version syntax');
|
|
264
345
|
const c = m.contributes || {};
|
|
346
|
+
const actionIds = new Set();
|
|
265
347
|
for (const a of c.actions || []) {
|
|
266
348
|
if (!ID_RE.test(a.id || '')) fail(`action.id invalid: ${a.id}`);
|
|
349
|
+
if (actionIds.has(a.id)) fail(`duplicate action.id: ${a.id}`);
|
|
350
|
+
actionIds.add(a.id);
|
|
267
351
|
if (typeof a.title !== 'string') fail(`action ${a.id}: title required`);
|
|
268
352
|
// An action INJECTS a command into an agentic session — it does NOT run the CLI.
|
|
269
353
|
if (!a.inject || typeof a.inject.command !== 'string') fail(`action ${a.id}: inject.command (string) required`);
|
|
354
|
+
if (!/^\/[a-z0-9][a-z0-9._-]*(?:\s[^\r\n]*)?$/i.test(a.inject.command)) fail(`action ${a.id}: inject.command must be one slash command without newlines`);
|
|
270
355
|
if (a.inject.target && !['focused', 'new'].includes(a.inject.target)) fail(`action ${a.id}: inject.target must be focused|new`);
|
|
271
356
|
}
|
|
272
357
|
for (const s of c.screens || []) {
|
|
273
358
|
if (!ID_RE.test(s.id || '')) fail(`screen.id invalid: ${s.id}`);
|
|
274
359
|
if (typeof s.title !== 'string') fail(`screen ${s.id}: title required`);
|
|
275
360
|
if (typeof s.source !== 'string') fail(`screen ${s.id}: source required`);
|
|
361
|
+
if (firstParty) {
|
|
362
|
+
if (!s.source.startsWith('cockpit://')) fail(`screen ${s.id}: first-party source must use cockpit://`);
|
|
363
|
+
} else if (!s.source.startsWith(`sandbox://${m.id}/`)) {
|
|
364
|
+
fail(`screen ${s.id}: third-party source must use sandbox://${m.id}/`);
|
|
365
|
+
}
|
|
276
366
|
}
|
|
277
367
|
for (const w of c.workflows || []) {
|
|
278
368
|
if (!ID_RE.test(w.id || '')) fail(`workflow.id invalid: ${w.id}`);
|
|
@@ -280,24 +370,39 @@ function validateContribution(m, where) {
|
|
|
280
370
|
if (!Array.isArray(w.steps) || w.steps.length === 0) fail(`workflow ${w.id}: steps required`);
|
|
281
371
|
for (const step of w.steps) {
|
|
282
372
|
if (!step || typeof step !== 'object' || !ID_RE.test(step.action || '')) fail(`workflow ${w.id}: step.action invalid`);
|
|
373
|
+
if (!actionIds.has(step.action)) fail(`workflow ${w.id}: unknown action ${step.action}`);
|
|
283
374
|
}
|
|
284
375
|
}
|
|
285
|
-
for (const h of c.hooks || []) {
|
|
376
|
+
for (const h of c.hooks || []) {
|
|
377
|
+
if (typeof h.on !== 'string' || !ID_RE.test(h.action || '') || !actionIds.has(h.action)) fail(`hook invalid: on=${h.on}`);
|
|
378
|
+
}
|
|
286
379
|
return m;
|
|
287
380
|
}
|
|
288
381
|
/** Load + validate + merge all contribution manifests across the configured dirs. */
|
|
289
382
|
async function loadContributions() {
|
|
290
383
|
const sources = [], actions = [], screens = [], hooks = [], workflows = [];
|
|
291
|
-
|
|
384
|
+
const manifestIds = new Set();
|
|
385
|
+
const itemIds = new Set();
|
|
386
|
+
for (const [dirIndex, dir] of CONTRIB_DIRS.entries()) {
|
|
387
|
+
const trustTier = dirIndex === 0 ? 'first-party' : 'sandboxed-third-party';
|
|
292
388
|
let entries = [];
|
|
293
389
|
try { entries = (await readdir(dir)).filter((f) => f.endsWith('.json') && f !== 'contribution.schema.json'); } catch { continue; }
|
|
294
390
|
for (const file of entries) {
|
|
295
|
-
const m = validateContribution(JSON.parse(await readFile(join(dir, file), 'utf8')), file);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
for (const
|
|
300
|
-
|
|
391
|
+
const m = validateContribution(JSON.parse(await readFile(join(dir, file), 'utf8')), file, { firstParty: dirIndex === 0 });
|
|
392
|
+
if (manifestIds.has(m.id)) throw new Error(`${file}: duplicate contribution id ${m.id}`);
|
|
393
|
+
manifestIds.add(m.id);
|
|
394
|
+
sources.push({ id: m.id, version: m.version, title: m.title ?? m.id, file, trust_tier: trustTier });
|
|
395
|
+
for (const [kind, rows] of Object.entries({ actions: m.contributes?.actions || [], screens: m.contributes?.screens || [], hooks: m.contributes?.hooks || [], workflows: m.contributes?.workflows || [] })) {
|
|
396
|
+
for (const row of rows) {
|
|
397
|
+
const globalId = `${kind}:${row.id ?? `${row.on}:${row.action}`}`;
|
|
398
|
+
if (itemIds.has(globalId)) throw new Error(`${file}: duplicate ${globalId}`);
|
|
399
|
+
itemIds.add(globalId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const a of m.contributes?.actions || []) actions.push({ ...a, source: m.id, trust_tier: trustTier });
|
|
403
|
+
for (const s of m.contributes?.screens || []) screens.push({ ...s, contribution: m.id, trust_tier: trustTier });
|
|
404
|
+
for (const h of m.contributes?.hooks || []) hooks.push({ ...h, source: m.id, trust_tier: trustTier });
|
|
405
|
+
for (const w of m.contributes?.workflows || []) workflows.push({ ...w, source: m.id, trust_tier: trustTier });
|
|
301
406
|
}
|
|
302
407
|
}
|
|
303
408
|
return { sources, actions, screens, hooks, workflows };
|
|
@@ -356,6 +461,42 @@ async function rebuildIndex(req) {
|
|
|
356
461
|
return { status: 200, body: { ok: true, command: `aiwg ${args.join(' ')}`, output, status } };
|
|
357
462
|
}
|
|
358
463
|
|
|
464
|
+
export async function createUserIndexGraph(body, projectRoot = process.cwd()) {
|
|
465
|
+
const name = safeIndexGraph(body?.name);
|
|
466
|
+
if (!name || ['project', 'codebase', 'framework'].includes(name)) {
|
|
467
|
+
throw new Error('name must be a non-built-in graph identifier');
|
|
468
|
+
}
|
|
469
|
+
const scanDirs = Array.isArray(body?.scanDirs) ? body.scanDirs.map((value) => String(value).trim()) : [];
|
|
470
|
+
if (!scanDirs.length || scanDirs.some((value) => !value || value.startsWith('/') || value.split(/[\\/]+/).includes('..'))) {
|
|
471
|
+
throw new Error('scanDirs must contain safe project-relative paths');
|
|
472
|
+
}
|
|
473
|
+
const extensions = Array.isArray(body?.extensions) && body.extensions.length
|
|
474
|
+
? body.extensions.map((value) => String(value).trim())
|
|
475
|
+
: ['.md', '.yaml', '.json'];
|
|
476
|
+
if (extensions.some((value) => !/^\.[a-z0-9]+$/i.test(value))) {
|
|
477
|
+
throw new Error('extensions must use forms such as .md or .json');
|
|
478
|
+
}
|
|
479
|
+
const configDir = join(projectRoot, '.aiwg');
|
|
480
|
+
const configPath = join(configDir, 'aiwg.config');
|
|
481
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
482
|
+
let config = {};
|
|
483
|
+
try { config = JSON.parse(await readFile(configPath, 'utf8')); }
|
|
484
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
485
|
+
config.index = config.index && typeof config.index === 'object' ? config.index : {};
|
|
486
|
+
config.index.graphs = config.index.graphs && typeof config.index.graphs === 'object' ? config.index.graphs : {};
|
|
487
|
+
if (config.index.graphs[name]) throw new Error(`graph '${name}' already exists`);
|
|
488
|
+
config.index.graphs[name] = {
|
|
489
|
+
scanDirs,
|
|
490
|
+
extensions,
|
|
491
|
+
defaultBuild: body?.defaultBuild === true,
|
|
492
|
+
shared: body?.shared === true,
|
|
493
|
+
};
|
|
494
|
+
const temporary = `${configPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
495
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
496
|
+
await rename(temporary, configPath);
|
|
497
|
+
return { name, definition: config.index.graphs[name], config_path: configPath };
|
|
498
|
+
}
|
|
499
|
+
|
|
359
500
|
function json(res, status, body) {
|
|
360
501
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
361
502
|
res.end(JSON.stringify(body));
|
|
@@ -2518,7 +2659,12 @@ export function createBridge({
|
|
|
2518
2659
|
if (url.pathname === '/api/mcp/discovery' && req.method === 'GET') return json(res, 200, await getMcpDiscovery(upstreamUrl));
|
|
2519
2660
|
if (url.pathname === '/api/mcp' && req.method === 'POST') return proxyMcpRequest(req, res, upstreamUrl, MCP_TOKEN_FILE);
|
|
2520
2661
|
if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
|
|
2521
|
-
if (url.pathname === '/api/missions') return json(res, 200, await getMissions(upstreamUrl));
|
|
2662
|
+
if (url.pathname === '/api/missions' && req.method === 'GET') return json(res, 200, await getMissions(upstreamUrl));
|
|
2663
|
+
if (url.pathname === '/api/missions' && req.method === 'POST') {
|
|
2664
|
+
const parsed = await readJsonBody(req);
|
|
2665
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
2666
|
+
return json(res, 201, await dispatchMission(parsed.body, upstreamUrl));
|
|
2667
|
+
}
|
|
2522
2668
|
if (url.pathname === '/api/events/snapshot') return json(res, 200, await getEventSnapshot(upstreamUrl));
|
|
2523
2669
|
if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
|
|
2524
2670
|
if (url.pathname === '/api/index/status' && req.method === 'GET') return json(res, 200, await getIndexStatus());
|
|
@@ -2530,6 +2676,19 @@ export function createBridge({
|
|
|
2530
2676
|
const result = await rebuildIndex(req);
|
|
2531
2677
|
return json(res, result.status, result.body);
|
|
2532
2678
|
}
|
|
2679
|
+
if (url.pathname === '/api/index/graphs' && req.method === 'POST') {
|
|
2680
|
+
const parsed = await readJsonBody(req);
|
|
2681
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
2682
|
+
const requested = await appendAudit('index.graph.create.requested', { graph: parsed.body?.name ?? null });
|
|
2683
|
+
try {
|
|
2684
|
+
const graph = await createUserIndexGraph(parsed.body);
|
|
2685
|
+
await appendAudit('index.graph.create.completed', { request_ts: requested.ts, graph: graph.name });
|
|
2686
|
+
return json(res, 201, { ok: true, graph });
|
|
2687
|
+
} catch (error) {
|
|
2688
|
+
await appendAudit('index.graph.create.rejected', { request_ts: requested.ts, reason: String(error?.message ?? error) });
|
|
2689
|
+
return json(res, 400, { error: 'invalid_graph_definition', detail: String(error?.message ?? error) });
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2533
2692
|
if (url.pathname === '/api/audit' && req.method === 'GET') {
|
|
2534
2693
|
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
|
|
2535
2694
|
return json(res, 200, { source: 'cockpit-bridge-audit/v1', audit: await readAudit({ limit }) });
|
|
@@ -2620,7 +2779,7 @@ export function createBridge({
|
|
|
2620
2779
|
if (type && type !== 'all') {
|
|
2621
2780
|
const types = type.split(',').map((t) => t.trim()).filter(Boolean);
|
|
2622
2781
|
if (!types.length || types.some((t) => !CAPABILITY_TYPES.has(t))) {
|
|
2623
|
-
return json(res, 400, { error: 'invalid_type', detail:
|
|
2782
|
+
return json(res, 400, { error: 'invalid_type', detail: `type must be all or a comma list of: ${[...CAPABILITY_TYPES].join(', ')}` });
|
|
2624
2783
|
}
|
|
2625
2784
|
args.push('--type', types.join(','));
|
|
2626
2785
|
}
|
|
@@ -2821,6 +2980,29 @@ export function createBridge({
|
|
|
2821
2980
|
await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
|
|
2822
2981
|
return json(res, status, body);
|
|
2823
2982
|
}
|
|
2983
|
+
if ((m = url.pathname.match(/^\/api\/missions\/([^/]+)\/(pause|resume)$/)) && req.method === 'POST') {
|
|
2984
|
+
const parsed = await readJsonBody(req);
|
|
2985
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
2986
|
+
const result = await controlMission({
|
|
2987
|
+
action: m[2],
|
|
2988
|
+
sessionId: decodeURIComponent(m[1]),
|
|
2989
|
+
expectedUpdatedAt: parsed.body?.expected_updated_at,
|
|
2990
|
+
requestId: parsed.body?.request_id,
|
|
2991
|
+
});
|
|
2992
|
+
return json(res, 200, { ...result, projection: await getMissions(upstreamUrl) });
|
|
2993
|
+
}
|
|
2994
|
+
if ((m = url.pathname.match(/^\/api\/missions\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST') {
|
|
2995
|
+
const parsed = await readJsonBody(req);
|
|
2996
|
+
if (parsed.error) return json(res, 400, { error: parsed.error });
|
|
2997
|
+
const result = await controlMission({
|
|
2998
|
+
action: 'cancel',
|
|
2999
|
+
sessionId: decodeURIComponent(m[1]),
|
|
3000
|
+
missionId: decodeURIComponent(m[2]),
|
|
3001
|
+
expectedUpdatedAt: parsed.body?.expected_updated_at,
|
|
3002
|
+
requestId: parsed.body?.request_id,
|
|
3003
|
+
});
|
|
3004
|
+
return json(res, 200, { ...result, projection: await getMissions(upstreamUrl) });
|
|
3005
|
+
}
|
|
2824
3006
|
if ((m = url.pathname.match(/^\/api\/tasks\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST') {
|
|
2825
3007
|
await appendAudit('task.cancel.requested', { instance_id: decodeURIComponent(m[1]), task_id: decodeURIComponent(m[2]) });
|
|
2826
3008
|
return proxy(res, 'POST', `${upstreamUrl}/agents/${encodeURIComponent(m[1])}/tasks/${encodeURIComponent(m[2])}:cancel`);
|
|
@@ -2867,7 +3049,7 @@ export function createBridge({
|
|
|
2867
3049
|
}
|
|
2868
3050
|
json(res, 404, { error: 'not_found', path: url.pathname });
|
|
2869
3051
|
} catch (err) {
|
|
2870
|
-
const status = Number(err?.upstreamStatus) || 502;
|
|
3052
|
+
const status = Number(err?.status) || Number(err?.upstreamStatus) || 502;
|
|
2871
3053
|
json(res, status, { error: err?.code ?? 'bridge_upstream_error', message: String(err?.message ?? err) });
|
|
2872
3054
|
}
|
|
2873
3055
|
};
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"required": ["command"],
|
|
32
32
|
"additionalProperties": false,
|
|
33
33
|
"properties": {
|
|
34
|
-
"command": { "type": "string", "description": "command
|
|
34
|
+
"command": { "type": "string", "pattern": "^/[a-zA-Z0-9][a-zA-Z0-9._-]*(\\s[^\\r\\n]*)?$", "description": "single slash command injected into an agentic session; newlines are forbidden" },
|
|
35
35
|
"target": { "type": "string", "enum": ["focused", "new"], "default": "focused", "description": "focused = inject into the attached session, else offer a new one; new = always a fresh session" },
|
|
36
36
|
"needs_args": { "type": "boolean", "description": "prompt for arguments before injecting" },
|
|
37
37
|
"args_hint": { "type": "string" }
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"properties": {
|
|
50
50
|
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
|
|
51
51
|
"title": { "type": "string" },
|
|
52
|
-
"source": { "type": "string", "description": "
|
|
52
|
+
"source": { "type": "string", "pattern": "^(cockpit|sandbox)://", "description": "first-party cockpit:// route or third-party sandbox://<contribution-id>/ resource" }
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwg/cockpit",
|
|
3
|
-
"version": "2026.8.
|
|
3
|
+
"version": "2026.8.4",
|
|
4
4
|
"description": "AIWG Cockpit — UX-first control plane over AIWG + multi-stack agentic sessions. Opt-in, separately published; NOT shipped in the base aiwg npm package (guarded by test/smoke/cockpit-base-footprint.test.js).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/shell-core/keychain.mjs
CHANGED
|
@@ -3,7 +3,6 @@ import { platform } from 'node:os';
|
|
|
3
3
|
|
|
4
4
|
const SERVICE = 'aiwg-cockpit-bridge';
|
|
5
5
|
const FOLDER = 'AIWG Cockpit';
|
|
6
|
-
const WALLET = process.env.AIWG_COCKPIT_KWALLET || 'kdewallet';
|
|
7
6
|
|
|
8
7
|
function collect(cmd, args, input, timeoutMs = 2_000) {
|
|
9
8
|
return new Promise((resolve, reject) => {
|
|
@@ -37,74 +36,94 @@ async function canRun(cmd) {
|
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Build the cross-platform keychain adapter around injectable platform and
|
|
41
|
+
* process seams. Production uses the defaults below; tests exercise the exact
|
|
42
|
+
* argv/stdin contract for every OS without pretending the CI host is macOS or
|
|
43
|
+
* Windows.
|
|
44
|
+
*/
|
|
45
|
+
export function createKeychainAdapter({
|
|
46
|
+
os = platform(),
|
|
47
|
+
env = process.env,
|
|
48
|
+
run = collect,
|
|
49
|
+
commandAvailable = canRun,
|
|
50
|
+
} = {}) {
|
|
51
|
+
const wallet = env.AIWG_COCKPIT_KWALLET || 'kdewallet';
|
|
45
52
|
|
|
46
|
-
async function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
async function windowsPowerShell() {
|
|
54
|
+
if (await commandAvailable('powershell')) return 'powershell';
|
|
55
|
+
if (await commandAvailable('pwsh')) return 'pwsh';
|
|
56
|
+
throw new Error('no supported Windows PowerShell command found');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function storeWindowsCredential(token, account) {
|
|
60
|
+
const ps = await windowsPowerShell();
|
|
61
|
+
const script = [
|
|
62
|
+
'[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]',
|
|
63
|
+
'$vault = New-Object Windows.Security.Credentials.PasswordVault',
|
|
64
|
+
'try { $vault.Remove($vault.Retrieve($args[0], $args[1])) } catch {}',
|
|
65
|
+
'$password = [Console]::In.ReadToEnd()',
|
|
66
|
+
'$credential = New-Object Windows.Security.Credentials.PasswordCredential -ArgumentList $args[0], $args[1], $password',
|
|
67
|
+
'$vault.Add($credential)',
|
|
68
|
+
].join('; ');
|
|
69
|
+
await run(ps, ['-NoProfile', '-NonInteractive', '-Command', script, SERVICE, account], token);
|
|
70
|
+
return { backend: 'windows-credential-manager', service: SERVICE, account, target: `${SERVICE}:${account}` };
|
|
71
|
+
}
|
|
59
72
|
|
|
60
|
-
async function readWindowsCredential(ref) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
async function readWindowsCredential(ref) {
|
|
74
|
+
const ps = await windowsPowerShell();
|
|
75
|
+
const script = [
|
|
76
|
+
'[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]',
|
|
77
|
+
'$vault = New-Object Windows.Security.Credentials.PasswordVault',
|
|
78
|
+
'$credential = $vault.Retrieve($args[0], $args[1])',
|
|
79
|
+
'$credential.RetrievePassword()',
|
|
80
|
+
'[Console]::Out.Write($credential.Password)',
|
|
81
|
+
].join('; ');
|
|
82
|
+
return (await run(ps, ['-NoProfile', '-NonInteractive', '-Command', script, ref.service || SERVICE, ref.account])).trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
async store(token, account = `bridge-${process.pid}`) {
|
|
87
|
+
if (env.AIWG_COCKPIT_KEYCHAIN_DISABLED === '1') {
|
|
88
|
+
throw new Error('OS keychain disabled by AIWG_COCKPIT_KEYCHAIN_DISABLED');
|
|
89
|
+
}
|
|
90
|
+
if (os === 'darwin' && await commandAvailable('security')) {
|
|
91
|
+
await run('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', token, '-U']);
|
|
92
|
+
return { backend: 'macos-keychain', service: SERVICE, account };
|
|
93
|
+
}
|
|
94
|
+
if (os === 'win32') return storeWindowsCredential(token, account);
|
|
95
|
+
if (await commandAvailable('secret-tool')) {
|
|
96
|
+
await run('secret-tool', ['store', '--label', 'AIWG Cockpit Bridge', 'service', SERVICE, 'account', account], token);
|
|
97
|
+
return { backend: 'libsecret', service: SERVICE, account };
|
|
98
|
+
}
|
|
99
|
+
if (env.AIWG_COCKPIT_ENABLE_KWALLET === '1' && await commandAvailable('kwallet-query')) {
|
|
100
|
+
await run('kwallet-query', ['-f', FOLDER, '-w', account, wallet], token);
|
|
101
|
+
return { backend: 'kwallet', service: SERVICE, account, wallet, folder: FOLDER };
|
|
102
|
+
}
|
|
103
|
+
throw new Error('no supported OS keychain command found');
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async read(ref) {
|
|
107
|
+
if (!ref || typeof ref !== 'object') throw new Error('missing keychain reference');
|
|
108
|
+
if (ref.backend === 'macos-keychain') {
|
|
109
|
+
return (await run('security', ['find-generic-password', '-a', ref.account, '-s', ref.service || SERVICE, '-w'])).trim();
|
|
110
|
+
}
|
|
111
|
+
if (ref.backend === 'windows-credential-manager') return readWindowsCredential(ref);
|
|
112
|
+
if (ref.backend === 'libsecret') {
|
|
113
|
+
return (await run('secret-tool', ['lookup', 'service', ref.service || SERVICE, 'account', ref.account])).trim();
|
|
114
|
+
}
|
|
115
|
+
if (ref.backend === 'kwallet') {
|
|
116
|
+
return (await run('kwallet-query', ['-f', ref.folder || FOLDER, '-r', ref.account, ref.wallet || wallet])).trim();
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`unsupported keychain backend: ${ref.backend}`);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
70
121
|
}
|
|
71
122
|
|
|
72
123
|
export async function storeCockpitToken(token, account = `bridge-${process.pid}`) {
|
|
73
|
-
|
|
74
|
-
throw new Error('OS keychain disabled by AIWG_COCKPIT_KEYCHAIN_DISABLED');
|
|
75
|
-
}
|
|
76
|
-
const os = platform();
|
|
77
|
-
if (os === 'darwin' && await canRun('security')) {
|
|
78
|
-
await collect('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', token, '-U']);
|
|
79
|
-
return { backend: 'macos-keychain', service: SERVICE, account };
|
|
80
|
-
}
|
|
81
|
-
if (os === 'win32') {
|
|
82
|
-
return storeWindowsCredential(token, account);
|
|
83
|
-
}
|
|
84
|
-
if (await canRun('secret-tool')) {
|
|
85
|
-
await collect('secret-tool', ['store', '--label', 'AIWG Cockpit Bridge', 'service', SERVICE, 'account', account], token);
|
|
86
|
-
return { backend: 'libsecret', service: SERVICE, account };
|
|
87
|
-
}
|
|
88
|
-
if (process.env.AIWG_COCKPIT_ENABLE_KWALLET === '1' && await canRun('kwallet-query')) {
|
|
89
|
-
await collect('kwallet-query', ['-f', FOLDER, '-w', account, WALLET], token);
|
|
90
|
-
return { backend: 'kwallet', service: SERVICE, account, wallet: WALLET, folder: FOLDER };
|
|
91
|
-
}
|
|
92
|
-
throw new Error('no supported OS keychain command found');
|
|
124
|
+
return createKeychainAdapter().store(token, account);
|
|
93
125
|
}
|
|
94
126
|
|
|
95
127
|
export async function readCockpitToken(ref) {
|
|
96
|
-
|
|
97
|
-
if (ref.backend === 'macos-keychain') {
|
|
98
|
-
return (await collect('security', ['find-generic-password', '-a', ref.account, '-s', ref.service || SERVICE, '-w'])).trim();
|
|
99
|
-
}
|
|
100
|
-
if (ref.backend === 'windows-credential-manager') {
|
|
101
|
-
return readWindowsCredential(ref);
|
|
102
|
-
}
|
|
103
|
-
if (ref.backend === 'libsecret') {
|
|
104
|
-
return (await collect('secret-tool', ['lookup', 'service', ref.service || SERVICE, 'account', ref.account])).trim();
|
|
105
|
-
}
|
|
106
|
-
if (ref.backend === 'kwallet') {
|
|
107
|
-
return (await collect('kwallet-query', ['-f', ref.folder || FOLDER, '-r', ref.account, ref.wallet || WALLET])).trim();
|
|
108
|
-
}
|
|
109
|
-
throw new Error(`unsupported keychain backend: ${ref.backend}`);
|
|
128
|
+
return createKeychainAdapter().read(ref);
|
|
110
129
|
}
|