@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.
@@ -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
- const CAPABILITY_TYPES = new Set(['skill', 'agent', 'command', 'rule', 'flow']);
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) => (code === 0 ? resolve(out) : reject(new Error(err.trim() || `aiwg exit ${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 (string) required');
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 || []) { if (typeof h.on !== 'string' || !ID_RE.test(h.action || '')) fail(`hook invalid: on=${h.on}`); }
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
- for (const dir of CONTRIB_DIRS) {
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
- sources.push({ id: m.id, version: m.version, title: m.title ?? m.id, file });
297
- for (const a of m.contributes?.actions || []) actions.push({ ...a, source: m.id });
298
- for (const s of m.contributes?.screens || []) screens.push({ ...s, contribution: m.id });
299
- for (const h of m.contributes?.hooks || []) hooks.push({ ...h, source: m.id });
300
- for (const w of m.contributes?.workflows || []) workflows.push({ ...w, source: m.id });
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: 'type must be all, skill, agent, command, rule, flow, or a comma list of those kinds' });
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/prompt injected into an agentic session" },
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": "url or path the screen iframe/region loads" }
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",
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",
@@ -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
- async function windowsPowerShell() {
41
- if (await canRun('powershell')) return 'powershell';
42
- if (await canRun('pwsh')) return 'pwsh';
43
- throw new Error('no supported Windows PowerShell command found');
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 storeWindowsCredential(token, account) {
47
- const ps = await windowsPowerShell();
48
- const script = [
49
- '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]',
50
- '$vault = New-Object Windows.Security.Credentials.PasswordVault',
51
- 'try { $vault.Remove($vault.Retrieve($args[0], $args[1])) } catch {}',
52
- '$password = [Console]::In.ReadToEnd()',
53
- '$credential = New-Object Windows.Security.Credentials.PasswordCredential -ArgumentList $args[0], $args[1], $password',
54
- '$vault.Add($credential)',
55
- ].join('; ');
56
- await collect(ps, ['-NoProfile', '-NonInteractive', '-Command', script, SERVICE, account], token);
57
- return { backend: 'windows-credential-manager', service: SERVICE, account, target: `${SERVICE}:${account}` };
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
- const ps = await windowsPowerShell();
62
- const script = [
63
- '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]',
64
- '$vault = New-Object Windows.Security.Credentials.PasswordVault',
65
- '$credential = $vault.Retrieve($args[0], $args[1])',
66
- '$credential.RetrievePassword()',
67
- '[Console]::Out.Write($credential.Password)',
68
- ].join('; ');
69
- return (await collect(ps, ['-NoProfile', '-NonInteractive', '-Command', script, ref.service || SERVICE, ref.account])).trim();
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
- if (process.env.AIWG_COCKPIT_KEYCHAIN_DISABLED === '1') {
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
- if (!ref || typeof ref !== 'object') throw new Error('missing keychain reference');
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
  }