@aiwg/cockpit 2026.6.10 → 2026.6.11

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 CHANGED
@@ -108,7 +108,7 @@ Cockpit package is installed under `~/.aiwg/cockpit/package`.
108
108
 
109
109
  ```
110
110
  operator / CLI: aiwg cockpit
111
- │ spawns the Bridge; writes ~/.aiwg/cockpit/runtime/bridge.json (token+port, 0600)
111
+ │ spawns the Bridge; writes OS keychain token + ~/.aiwg/cockpit/runtime/bridge.json (0600)
112
112
  ▼
113
113
  ┌─────────────────────────────────────────────────────────────┐
114
114
  │ Bridge (127.0.0.1, token-gated /api) │
@@ -228,7 +228,7 @@ React app token-injected, falling back to a legacy page when no build is present
228
228
  | `web/` | React 19 + Vite + TS UI (the surfaces above) |
229
229
  | `mock-executor/` | **automated-test-only** wire-faithful agentic-sandbox A2A v2 stand-in (conformance 33/0/17). The Bridge refuses it for human launches (needs `AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR=1`); a contract guard (#1636) pins its legacy `/admin/{running,approvals,cost}` divergence from real v2 so new drift fails CI. |
230
230
  | `bridge/` | the registry-bound control-plane server + static serving |
231
- | `shell-core/` | the cross-shell handshake (runtime token → connect) |
231
+ | `shell-core/` | the cross-shell handshake (runtime token reference or fallback token → connect) |
232
232
  | `vscode/` · `desktop/` | VS Code extension + Tauri shells over the same Bridge |
233
233
  | `contrib/` | declarative UI contributions + schema (actions inject commands) |
234
234
  | `poc/` | Iteration-1 risk-gate PoCs (kill-bridge isolation, security) |
@@ -317,11 +317,12 @@ injection can mutate target data, set
317
317
  managed PTY session on the same target, observes it, drives a shell command via
318
318
  `pty.session_input`, waits for `AIWG_COCKPIT_MUTATION_OK`, then reads the file
319
319
  from the test runner and verifies the exact content.
320
- The matrix report records each target family independently (`matrix host`,
321
- `matrix container`, `matrix vm`) with the instance, runtime family, selected
322
- session backend, provider, discovery expectation, and exact failure reason; the
323
- test aggregates those records and fails only after all three target families have
324
- been attempted. Mock-only success does not satisfy this gate;
320
+ The matrix report records each target family independently (`provision host`,
321
+ `matrix host`, `provision container`, `matrix container`, `provision vm`,
322
+ `matrix vm`) with the instance, runtime family, selected session backend,
323
+ provider, discovery expectation, running-projection count, report artifact paths,
324
+ and exact failure reason; the test aggregates those records and fails only after
325
+ all requested target families have been attempted. Mock-only success does not satisfy this gate;
325
326
  `AIWG_COCKPIT_LIVE_ALLOW_MOCK_MATRIX=1` exists only for harness development.
326
327
 
327
328
  To prove the launch path itself, set `AIWG_COCKPIT_LIVE_PROVISION=1`. In this
@@ -148,7 +148,11 @@
148
148
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
149
149
  // per-launch token injected by the Bridge for the gated control surface
150
150
  const TOKEN = window.__COCKPIT_TOKEN__ || '';
151
- const api = (u, o = {}) => fetch(u, { ...o, headers: { ...(o.headers || {}), authorization: 'Bearer ' + TOKEN } });
151
+ const api = (u, o = {}) => {
152
+ const method = String(o.method || 'GET').toUpperCase();
153
+ const csrf = ['GET', 'HEAD', 'OPTIONS'].includes(method) ? {} : { 'x-cockpit-csrf': TOKEN };
154
+ return fetch(u, { ...o, headers: { ...(o.headers || {}), ...csrf, authorization: 'Bearer ' + TOKEN } });
155
+ };
152
156
 
153
157
  // --- Inventory ---
154
158
  async function loadInventory() {
@@ -379,7 +383,7 @@
379
383
  }
380
384
  }
381
385
 
382
- document.getElementById('refresh').addEventListener('click', () => {
386
+ function refreshActive() {
383
387
  const active = tabs.find((t) => t.getAttribute('aria-selected') === 'true').id;
384
388
  if (active === 'tab-inventory') loadInventory();
385
389
  else if (active === 'tab-running') loadRunning();
@@ -387,7 +391,12 @@
387
391
  else if (active === 'tab-approvals') loadApprovals();
388
392
  else if (active === 'tab-actions') loadActions();
389
393
  else discover();
390
- });
394
+ }
395
+ document.getElementById('refresh').addEventListener('click', refreshActive);
396
+ if ('EventSource' in window && TOKEN) {
397
+ const events = new EventSource('/api/events?token=' + encodeURIComponent(TOKEN));
398
+ events.addEventListener('cockpit.refresh', refreshActive);
399
+ }
391
400
 
392
401
  loadInventory();
393
402
  </script>
@@ -13,6 +13,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
13
13
  import { homedir } from 'node:os';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { dirname, join, basename, extname, resolve, sep } from 'node:path';
16
+ import { storeCockpitToken } from '../../shell-core/keychain.mjs';
16
17
 
17
18
  const __dir = dirname(fileURLToPath(import.meta.url));
18
19
  // Primary seam for roctinam/aiwg#1589: Cockpit talks to a real agentic-sandbox
@@ -52,11 +53,47 @@ function authed(req, url, token) {
52
53
  try { return timingSafeEqual(Buffer.from(presented), Buffer.from(token)); } catch { return false; }
53
54
  }
54
55
 
56
+ function isLocalHostName(hostname) {
57
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
58
+ }
59
+
60
+ function validBrowserOrigin(req) {
61
+ const origin = req.headers.origin;
62
+ if (!origin) return true;
63
+ try {
64
+ const o = new URL(String(origin));
65
+ const host = new URL(`http://${req.headers.host ?? 'localhost'}`);
66
+ return ['http:', 'https:'].includes(o.protocol) &&
67
+ isLocalHostName(o.hostname) &&
68
+ isLocalHostName(host.hostname) &&
69
+ (!o.port || !host.port || o.port === host.port);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function validCsrf(req, token) {
76
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method ?? 'GET')) return true;
77
+ if (!req.headers.origin) return true;
78
+ const csrf = String(req.headers['x-cockpit-csrf'] ?? '');
79
+ if (csrf.length !== token.length) return false;
80
+ try { return timingSafeEqual(Buffer.from(csrf), Buffer.from(token)); } catch { return false; }
81
+ }
82
+
55
83
  /** Persist the per-launch token for the desktop/VS Code shells to read (mode 600). */
56
84
  async function writeRuntimeToken({ token, port, pid }) {
57
85
  await mkdir(RUNTIME_DIR, { recursive: true, mode: 0o700 });
58
86
  const file = join(RUNTIME_DIR, 'bridge.json');
59
- await writeFile(file, JSON.stringify({ token, port, pid, started_at: new Date().toISOString() }, null, 2), { mode: 0o600 });
87
+ const runtime = { token, port, pid, started_at: new Date().toISOString(), keychain_backed: false };
88
+ try {
89
+ runtime.token_ref = await storeCockpitToken(token, `bridge-${pid}`);
90
+ runtime.keychain_backed = true;
91
+ if (process.env.AIWG_COCKPIT_KEYCHAIN_STRICT === '1') delete runtime.token;
92
+ } catch (e) {
93
+ runtime.keychain_error = String(e?.message ?? e);
94
+ if (process.env.AIWG_COCKPIT_REQUIRE_KEYCHAIN === '1') throw e;
95
+ }
96
+ await writeFile(file, JSON.stringify(runtime, null, 2), { mode: 0o600 });
60
97
  await chmod(file, 0o600);
61
98
  return file;
62
99
  }
@@ -751,36 +788,129 @@ async function getRunning(executorUrl) {
751
788
  };
752
789
  }
753
790
 
791
+ function textFromParts(parts) {
792
+ if (!Array.isArray(parts)) return '';
793
+ return parts
794
+ .map((p) => p?.text ?? p?.content ?? p?.value ?? '')
795
+ .filter((p) => typeof p === 'string' && p.trim())
796
+ .join('\n');
797
+ }
798
+
799
+ function approvalPromptFromTask(task) {
800
+ const meta = task.metadata ?? {};
801
+ const status = typeof task.status === 'object' ? task.status : {};
802
+ const prompt = [
803
+ meta.hitl_prompt?.prompt,
804
+ meta.hitlPrompt?.prompt,
805
+ meta.approval?.prompt,
806
+ meta.prompt,
807
+ status.message,
808
+ status.prompt,
809
+ textFromParts(task.artifacts?.flatMap((a) => a.parts ?? [])),
810
+ textFromParts(task.history?.at?.(-1)?.parts),
811
+ ].find((v) => typeof v === 'string' && v.trim());
812
+ return String(prompt || 'Human input required');
813
+ }
814
+
815
+ function approvalFromTask(instance, task) {
816
+ const state = taskState(task);
817
+ const meta = task.metadata ?? {};
818
+ const hasHitlPrompt = meta.hitl_prompt || meta.hitlPrompt || meta.approval || meta['hitl-prompt/v1'];
819
+ if (state !== 'input-required' && !hasHitlPrompt) return null;
820
+ const taskId = taskIdOf(task);
821
+ if (!taskId) return null;
822
+ return {
823
+ id: `${instance.id}::${taskId}`,
824
+ instance_id: instance.id,
825
+ task_id: taskId,
826
+ prompt: approvalPromptFromTask(task),
827
+ risk: meta.risk ?? meta.approval?.risk ?? meta.hitl_prompt?.risk ?? 'unknown',
828
+ created_at: task.created_at ?? task.createdAt ?? task.status?.timestamp ?? task.metadata?.created_at,
829
+ status: state === 'input-required' ? 'pending' : state,
830
+ tenant: taskTenantOf(task),
831
+ derived: 'a2a input-required task',
832
+ };
833
+ }
834
+
754
835
  /**
755
- * Pending HITL approvals (the unified approval inbox). The real agentic-sandbox
756
- * v2 admin surface has no /approvals route — HITL prompts arrive via A2A
757
- * `input-required` / `hitl-prompt/v1`. Deriving the inbox (and routing the
758
- * decision back to the task) from that surface is the remaining half of the v2
759
- * work (#1639 follow-up, with #1565); until then degrade to an empty inbox
760
- * rather than 404 so the operator Home view stays usable against a real executor.
836
+ * Pending HITL approvals (the unified approval inbox) derived from real A2A
837
+ * `input-required` / `hitl-prompt/v1` task surfaces. The real agentic-sandbox
838
+ * v2 admin router has no approvals queue, so this deliberately does not probe
839
+ * `/admin/approvals`.
761
840
  */
762
841
  async function getApprovals(executorUrl, status) {
763
- let body;
764
- try {
765
- ({ body } = await fetchJsonFirst([
766
- `${executorUrl}/admin/approvals?status=${encodeURIComponent(status)}`,
767
- `${executorUrl}/api/v2/admin/approvals?status=${encodeURIComponent(status)}`,
768
- ]));
769
- } catch {
770
- return {
771
- source: executorUrl,
772
- fetched_at: new Date().toISOString(),
773
- approvals: [],
774
- derived: 'executor exposes no admin approvals endpoint',
775
- };
776
- }
842
+ const instances = (await getInventory(executorUrl)).instances;
843
+ const approvals = [];
844
+ await Promise.all(
845
+ instances.filter((i) => i.state === 'running').map(async (inst) => {
846
+ let tasks;
847
+ try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
848
+ for (const t of tasks) {
849
+ const approval = approvalFromTask(inst, t);
850
+ if (!approval) continue;
851
+ if (status && status !== 'all' && approval.status !== status) continue;
852
+ approvals.push(approval);
853
+ }
854
+ }),
855
+ );
777
856
  return {
778
857
  source: executorUrl,
779
858
  fetched_at: new Date().toISOString(),
780
- approvals: asArrayFromEnvelope(body, ['approvals', 'items', 'data']),
859
+ approvals,
860
+ derived: 'per-instance A2A input-required tasks',
781
861
  };
782
862
  }
783
863
 
864
+ async function respondApproval(executorUrl, approvalId, decision) {
865
+ if (!['approve', 'deny'].includes(decision)) return { status: 400, body: { error: 'decision must be approve|deny' } };
866
+ const [instanceId, taskId] = String(approvalId).split('::');
867
+ if (!instanceId || !taskId) return { status: 400, body: { error: 'invalid_approval_id' } };
868
+ const agentId = await resolveSessionAgentId(executorUrl, instanceId);
869
+ const message = {
870
+ message: {
871
+ messageId: `cockpit-hitl-${Date.now()}`,
872
+ role: 'user',
873
+ taskId,
874
+ contextId: taskId,
875
+ parts: [{ kind: 'text', text: decision }],
876
+ metadata: { hitl_response: { decision }, approval_decision: decision },
877
+ },
878
+ };
879
+ const response = JSON.stringify({ decision, response: message.message });
880
+ const candidates = unique([agentId, instanceId]).flatMap((id) => [
881
+ {
882
+ target: `${executorUrl}/api/v1/agents/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}:respond`,
883
+ method: 'POST',
884
+ headers: { 'content-type': 'application/json' },
885
+ body: response,
886
+ },
887
+ {
888
+ target: `${executorUrl}/agents/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}:respond`,
889
+ method: 'POST',
890
+ headers: { 'content-type': 'application/json' },
891
+ body: response,
892
+ },
893
+ {
894
+ target: `${executorUrl}/api/v1/agents/${encodeURIComponent(id)}/messages:send`,
895
+ method: 'POST',
896
+ headers: { 'content-type': 'application/json' },
897
+ body: JSON.stringify(message),
898
+ },
899
+ {
900
+ target: `${executorUrl}/agents/${encodeURIComponent(id)}/messages:send`,
901
+ method: 'POST',
902
+ headers: { 'content-type': 'application/json' },
903
+ body: JSON.stringify(message),
904
+ },
905
+ ]);
906
+ try {
907
+ const { status, body } = await fetchJsonFirst(candidates);
908
+ return { status, body };
909
+ } catch (e) {
910
+ return { status: 409, body: { error: 'approval_response_failed', detail: String(e?.message ?? e) } };
911
+ }
912
+ }
913
+
784
914
  /**
785
915
  * Sessions for one instance, each with a direct attach_url. Control plane (this
786
916
  * list) goes through the Bridge; the data plane (the pty stream) connects direct
@@ -864,10 +994,16 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
864
994
  try {
865
995
  // unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
866
996
  if (url.pathname === '/healthz') return json(res, 200, { status: 'ok' });
997
+ if (url.pathname.startsWith('/api/') && !validBrowserOrigin(req)) {
998
+ return json(res, 403, { error: 'forbidden_origin' });
999
+ }
867
1000
  // gate the control surface: per-launch bearer token on every /api/ call
868
1001
  if (url.pathname.startsWith('/api/') && !authed(req, url, TOKEN)) {
869
1002
  return json(res, 401, { error: 'unauthorized', detail: 'missing or invalid cockpit token' });
870
1003
  }
1004
+ if (url.pathname.startsWith('/api/') && !validCsrf(req, TOKEN)) {
1005
+ return json(res, 403, { error: 'csrf_required' });
1006
+ }
871
1007
  if (url.pathname.startsWith('/api/')) {
872
1008
  try {
873
1009
  await assertRealExecutor(upstreamUrl, allowMockExecutor);
@@ -875,6 +1011,21 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
875
1011
  return json(res, 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err) });
876
1012
  }
877
1013
  }
1014
+ if (url.pathname === '/api/events' && req.method === 'GET') {
1015
+ res.writeHead(200, {
1016
+ 'content-type': 'text/event-stream',
1017
+ 'cache-control': 'no-cache',
1018
+ connection: 'keep-alive',
1019
+ });
1020
+ const emit = (reason = 'heartbeat') => {
1021
+ res.write(`event: cockpit.refresh\n`);
1022
+ res.write(`data: ${JSON.stringify({ reason, ts: new Date().toISOString() })}\n\n`);
1023
+ };
1024
+ emit('connected');
1025
+ const timer = setInterval(() => emit(), 5_000);
1026
+ req.on('close', () => clearInterval(timer));
1027
+ return;
1028
+ }
878
1029
  if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
879
1030
  if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
880
1031
  if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
@@ -1066,8 +1217,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1066
1217
  // --- approval inbox (UC-009) + cost (UC-010) ---
1067
1218
  if (url.pathname === '/api/approvals' && req.method === 'GET')
1068
1219
  return json(res, 200, await getApprovals(upstreamUrl, url.searchParams.get('status') || 'pending'));
1069
- if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST')
1070
- return proxy(res, 'POST', `${upstreamUrl}/admin/approvals/${encodeURIComponent(m[1])}?decision=${encodeURIComponent(url.searchParams.get('decision') || '')}`);
1220
+ if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST') {
1221
+ const { status, body } = await respondApproval(upstreamUrl, decodeURIComponent(m[1]), url.searchParams.get('decision') || '');
1222
+ return json(res, status, body);
1223
+ }
1071
1224
  if (url.pathname === '/api/cost' && req.method === 'GET')
1072
1225
  return proxy(res, 'GET', `${upstreamUrl}/admin/cost`);
1073
1226
 
@@ -1079,7 +1232,11 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1079
1232
  // Inject the per-launch token so the same-origin app can call the gated API.
1080
1233
  const html = raw.replace('</head>', `<script>window.__COCKPIT_TOKEN__=${JSON.stringify(TOKEN)}</script>\n</head>`);
1081
1234
  // never cache the shell — it must always reference the latest hashed bundle
1082
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
1235
+ res.writeHead(200, {
1236
+ 'content-type': 'text/html; charset=utf-8',
1237
+ 'cache-control': 'no-cache',
1238
+ 'set-cookie': `cockpit_csrf=${TOKEN}; Path=/; SameSite=Strict`,
1239
+ });
1083
1240
  return res.end(html);
1084
1241
  }
1085
1242
  // static assets from the built web app (e.g. /assets/*.js, *.css)
@@ -106,9 +106,11 @@ try {
106
106
 
107
107
  // approval inbox (UC-009)
108
108
  const pend = await (await f('/api/approvals?status=pending')).json();
109
- assert.ok(pend.approvals.length >= 2, 'pending approvals seeded');
110
- const apr = await (await f('/api/approvals/apr-001?decision=approve', { method: 'POST' })).json();
111
- assert.equal(apr.status, 'approved', 'approval resolves to approved');
109
+ assert.equal(pend.derived, 'per-instance A2A input-required tasks', 'approvals derive from A2A tasks');
110
+ assert.ok(pend.approvals.length >= 1, 'pending approvals seeded');
111
+ const approvalId = pend.approvals[0].id;
112
+ const apr = await (await f(`/api/approvals/${encodeURIComponent(approvalId)}?decision=approve`, { method: 'POST' })).json();
113
+ assert.equal(apr.status.state, 'completed', 'approval response completes the task');
112
114
  const pend2 = await (await f('/api/approvals?status=pending')).json();
113
115
  assert.equal(pend2.approvals.length, pend.approvals.length - 1, 'approved item leaves the queue');
114
116
 
package/desktop/README.md CHANGED
@@ -3,14 +3,14 @@
3
3
  A lightweight native window hosting the **same registry-bound Bridge UI** as the
4
4
  VS Code shell and the browser. The shell does not replace the CLI or reimplement
5
5
  the control plane — `src-tauri/src/main.rs` waits for the Bridge's per-launch
6
- runtime token file (`~/.aiwg/cockpit/runtime/bridge.json`) and opens a window at
7
- the Bridge UI with the token on the query string.
6
+ runtime handshake file (`~/.aiwg/cockpit/runtime/bridge.json`) and opens a window
7
+ at the Bridge UI with the resolved per-launch token on the query string.
8
8
 
9
9
  ## Architecture
10
10
 
11
11
  ```
12
12
  operator/CLI: aiwg cockpit
13
- │ (spawns the Bridge; writes runtime/bridge.json mode 600)
13
+ │ (spawns the Bridge; writes OS keychain token + runtime/bridge.json mode 600)
14
14
  ▼
15
15
  Bridge (127.0.0.1:PORT, token-gated /api) ── proxies ──▶ agentic-sandbox executor
16
16
  ▲
@@ -38,5 +38,6 @@ set needed by Tauri. `cargo tauri build` has been verified on Linux to produce
38
38
  ## Why a token file (not a socket handshake)
39
39
 
40
40
  The runtime file is the cross-platform handshake every shell shares (see
41
- `apps/cockpit/shell-core/runtime.mjs`). It is mode `600`; OS-keychain storage is a
42
- per-platform hardening follow-up (roctinam/aiwg#1595).
41
+ `apps/cockpit/shell-core/runtime.mjs`). The Bridge stores the per-launch token in
42
+ the OS credential backend when available and records a `token_ref`; `bridge.json`
43
+ is mode `600` and records explicit fallback evidence when no backend is usable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.6.10",
3
+ "version": "2026.6.11",
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",
@@ -8,7 +8,7 @@ the local control surface lives here.
8
8
 
9
9
  | File | Written by | Mode | Contents |
10
10
  |---|---|---|---|
11
- | `bridge.json` | the Bridge on launch | `0600` | `{ token, port, pid, started_at }` — the per-launch handshake every shell reads |
11
+ | `bridge.json` | the Bridge on launch | `0600` | `{ token_ref, port, pid, started_at, keychain_backed }` when OS-keychain storage succeeds; otherwise `{ token, port, pid, started_at, keychain_backed:false, keychain_error }` |
12
12
 
13
13
  The directory itself is `0700`. The Bridge **rewrites** `bridge.json` on each launch
14
14
  (the token is per-launch, not persistent).
@@ -18,18 +18,26 @@ The directory itself is `0700`. The Bridge **rewrites** `bridge.json` on each la
18
18
  Every shell (browser, VS Code, Tauri) resolves the Bridge the same way — see
19
19
  `apps/cockpit/shell-core/runtime.mjs`:
20
20
 
21
- 1. read `bridge.json` → `{ token, port }`
22
- 2. wait for `http://127.0.0.1:<port>/healthz`
23
- 3. load the UI at `http://127.0.0.1:<port>/?token=<token>`
21
+ 1. read `bridge.json` → `{ token_ref, port }` or fallback `{ token, port }`
22
+ 2. resolve `token_ref` through `apps/cockpit/shell-core/keychain.mjs` when present
23
+ 3. wait for `http://127.0.0.1:<port>/healthz`
24
+ 4. load the UI at `http://127.0.0.1:<port>/?token=<token>`
24
25
 
25
26
  ## Security
26
27
 
27
- - `bridge.json` holds **only the overlay's own per-launch token** — never a provider
28
- or stack credential (verified by `apps/cockpit/poc/security-checks.mjs`, property I1).
28
+ - The per-launch token is written to the OS credential backend when one is available:
29
+ macOS Keychain (`security`), Windows Credential Manager (`cmdkey`), Linux libsecret
30
+ (`secret-tool`), or opt-in KDE Wallet (`AIWG_COCKPIT_ENABLE_KWALLET=1`).
31
+ - `bridge.json` holds **only the overlay's own per-launch token or token reference** —
32
+ never a provider or stack credential (verified by
33
+ `apps/cockpit/poc/security-checks.mjs`, property I1). Set
34
+ `AIWG_COCKPIT_KEYCHAIN_STRICT=1` to omit the inline token when keychain storage
35
+ succeeds; set `AIWG_COCKPIT_REQUIRE_KEYCHAIN=1` to fail Bridge launch if no OS
36
+ credential backend is usable.
29
37
  - `token` gates every `/api/*` call (constant-time bearer check); `tenant_id` elsewhere
30
38
  is a **routing** token, never authentication.
31
- - OS-keychain storage of the token is the platform-specific hardening follow-up
32
- (roctinam/aiwg#1595); the `0600` file is the cross-platform handshake.
39
+ - Browser-origin `/api/*` calls are localhost-origin checked, and state-changing
40
+ browser calls must include the CSRF double-submit header emitted by the web clients.
33
41
 
34
42
  ## Launch-cwd model
35
43
 
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { platform } from 'node:os';
3
+
4
+ const SERVICE = 'aiwg-cockpit-bridge';
5
+ const FOLDER = 'AIWG Cockpit';
6
+ const WALLET = process.env.AIWG_COCKPIT_KWALLET || 'kdewallet';
7
+
8
+ function collect(cmd, args, input, timeoutMs = 2_000) {
9
+ return new Promise((resolve, reject) => {
10
+ const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
11
+ let stdout = '';
12
+ let stderr = '';
13
+ const timer = setTimeout(() => {
14
+ p.kill();
15
+ reject(new Error(`${cmd} timed out`));
16
+ }, timeoutMs);
17
+ p.stdout.on('data', (d) => { stdout += d; });
18
+ p.stderr.on('data', (d) => { stderr += d; });
19
+ p.once('error', (err) => { clearTimeout(timer); reject(err); });
20
+ p.once('close', (code) => {
21
+ clearTimeout(timer);
22
+ if (code === 0) resolve(stdout);
23
+ else reject(new Error(stderr.trim() || `${cmd} exit ${code}`));
24
+ });
25
+ if (input !== undefined) p.stdin.end(input);
26
+ else p.stdin.end();
27
+ });
28
+ }
29
+
30
+ async function canRun(cmd) {
31
+ try {
32
+ if (process.platform === 'win32') await collect('where', [cmd]);
33
+ else await collect('sh', ['-lc', `command -v ${cmd}`]);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export async function storeCockpitToken(token, account = `bridge-${process.pid}`) {
41
+ const os = platform();
42
+ if (os === 'darwin' && await canRun('security')) {
43
+ await collect('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', token, '-U']);
44
+ return { backend: 'macos-keychain', service: SERVICE, account };
45
+ }
46
+ if (os === 'win32' && await canRun('cmdkey')) {
47
+ const target = `${SERVICE}:${account}`;
48
+ await collect('cmdkey', [`/generic:${target}`, `/user:${account}`, `/pass:${token}`]);
49
+ return { backend: 'windows-credential-manager', service: SERVICE, account, target };
50
+ }
51
+ if (await canRun('secret-tool')) {
52
+ await collect('secret-tool', ['store', '--label', 'AIWG Cockpit Bridge', 'service', SERVICE, 'account', account], token);
53
+ return { backend: 'libsecret', service: SERVICE, account };
54
+ }
55
+ if (process.env.AIWG_COCKPIT_ENABLE_KWALLET === '1' && await canRun('kwallet-query')) {
56
+ await collect('kwallet-query', ['-f', FOLDER, '-w', account, WALLET], token);
57
+ return { backend: 'kwallet', service: SERVICE, account, wallet: WALLET, folder: FOLDER };
58
+ }
59
+ throw new Error('no supported OS keychain command found');
60
+ }
61
+
62
+ export async function readCockpitToken(ref) {
63
+ if (!ref || typeof ref !== 'object') throw new Error('missing keychain reference');
64
+ if (ref.backend === 'macos-keychain') {
65
+ return (await collect('security', ['find-generic-password', '-a', ref.account, '-s', ref.service || SERVICE, '-w'])).trim();
66
+ }
67
+ if (ref.backend === 'windows-credential-manager') {
68
+ throw new Error('Windows Credential Manager read requires the shell-provided runtime token until native shell integration lands');
69
+ }
70
+ if (ref.backend === 'libsecret') {
71
+ return (await collect('secret-tool', ['lookup', 'service', ref.service || SERVICE, 'account', ref.account])).trim();
72
+ }
73
+ if (ref.backend === 'kwallet') {
74
+ return (await collect('kwallet-query', ['-f', ref.folder || FOLDER, '-r', ref.account, ref.wallet || WALLET])).trim();
75
+ }
76
+ throw new Error(`unsupported keychain backend: ${ref.backend}`);
77
+ }
@@ -1,19 +1,22 @@
1
1
  // Shell-core: the handshake every Cockpit shell (VS Code, Tauri, browser) shares.
2
2
  // The Bridge writes ~/.aiwg/cockpit/runtime/bridge.json (mode 600) on launch with
3
- // { token, port }. A shell reads it, waits for liveness, and loads the Bridge UI at
3
+ // { token_ref, port } when OS-keychain storage is available, else { token, port }.
4
+ // A shell resolves the token, waits for liveness, and loads the Bridge UI at
4
5
  // <url>/?token=<token>. Control plane is the gated Bridge API; data plane (pty) is
5
6
  // the executor URL the Bridge issues. This module is the one source of that contract.
6
7
  import { readFile } from 'node:fs/promises';
7
8
  import { homedir } from 'node:os';
8
9
  import { join } from 'node:path';
10
+ import { readCockpitToken } from './keychain.mjs';
9
11
 
10
12
  export const RUNTIME_FILE = join(homedir(), '.aiwg', 'cockpit', 'runtime', 'bridge.json');
11
13
 
12
14
  /** Read the per-launch Bridge connection (token, port, url). Throws if not launched. */
13
15
  export async function readRuntime(file = RUNTIME_FILE) {
14
16
  const r = JSON.parse(await readFile(file, 'utf8'));
15
- if (!r.token || !r.port) throw new Error(`runtime file ${file} missing token/port`);
16
- return { ...r, url: `http://127.0.0.1:${r.port}` };
17
+ const token = r.token || await readCockpitToken(r.token_ref);
18
+ if (!token || !r.port) throw new Error(`runtime file ${file} missing token/port`);
19
+ return { ...r, token, url: `http://127.0.0.1:${r.port}` };
17
20
  }
18
21
 
19
22
  /** Resolve + wait for the Bridge to be reachable; returns { token, port, url }.
package/vscode/README.md CHANGED
@@ -8,12 +8,12 @@ contributed actions as command-palette entries. No build step (CommonJS
8
8
 
9
9
  | Command | Effect |
10
10
  |---|---|
11
- | **AIWG Cockpit: Open** | Opens the Cockpit UI in a webview (reads the Bridge runtime token, loads `http://127.0.0.1:PORT/?token=…`). |
12
- | **AIWG Cockpit: Audit Issues** | Runs the contributed `audit-issues` action through the Bridge and prints the result to an output channel. |
11
+ | **AIWG Cockpit: Open** | Opens the Cockpit UI in a webview (reads the Bridge runtime handshake, resolves the token, loads `http://127.0.0.1:PORT/?token=…`). |
12
+ | **AIWG Cockpit: Audit Issues** | Opens Cockpit on the contributed Actions view; the action injects into an agentic session instead of running from the extension. |
13
13
 
14
14
  ## Run it
15
15
 
16
- 1. Launch the Bridge: `aiwg cockpit` (or, in-repo, `node apps/cockpit/bridge/src/server.mjs`). It writes `~/.aiwg/cockpit/runtime/bridge.json` (token + port, mode 600).
16
+ 1. Launch the Bridge: `aiwg cockpit` (or, in-repo, `node apps/cockpit/bridge/src/server.mjs`). It writes `~/.aiwg/cockpit/runtime/bridge.json` (token reference + port when OS-keychain storage is available, otherwise token + port, mode 600).
17
17
  2. In VS Code: **F5** (Extension Development Host) from this folder, or install the packaged `.vsix`.
18
18
  3. Run **AIWG Cockpit: Open** from the command palette.
19
19
 
@@ -7,18 +7,34 @@ const vscode = require('vscode');
7
7
  const fs = require('fs');
8
8
  const os = require('os');
9
9
  const path = require('path');
10
+ const cp = require('child_process');
10
11
 
11
12
  function runtimeFile() {
12
13
  const override = vscode.workspace.getConfiguration('aiwg-cockpit').get('bridgeRuntimeFile');
13
14
  return override && override.length ? override : path.join(os.homedir(), '.aiwg', 'cockpit', 'runtime', 'bridge.json');
14
15
  }
15
16
 
17
+ function readTokenRef(ref) {
18
+ if (!ref || !ref.backend) return '';
19
+ if (ref.backend === 'macos-keychain') {
20
+ return cp.execFileSync('security', ['find-generic-password', '-a', ref.account, '-s', ref.service || 'aiwg-cockpit-bridge', '-w'], { encoding: 'utf8' }).trim();
21
+ }
22
+ if (ref.backend === 'libsecret') {
23
+ return cp.execFileSync('secret-tool', ['lookup', 'service', ref.service || 'aiwg-cockpit-bridge', 'account', ref.account], { encoding: 'utf8' }).trim();
24
+ }
25
+ if (ref.backend === 'kwallet') {
26
+ return cp.execFileSync('kwallet-query', ['-f', ref.folder || 'AIWG Cockpit', '-r', ref.account, ref.wallet || 'kdewallet'], { encoding: 'utf8' }).trim();
27
+ }
28
+ throw new Error(`Unsupported Cockpit keychain backend: ${ref.backend}`);
29
+ }
30
+
16
31
  /** Read the Bridge connection + confirm liveness; throws with a friendly hint if down. */
17
32
  async function ensureRuntime() {
18
33
  let rt;
19
34
  try {
20
35
  const r = JSON.parse(fs.readFileSync(runtimeFile(), 'utf8'));
21
- rt = { ...r, url: `http://127.0.0.1:${r.port}` };
36
+ const token = r.token || readTokenRef(r.token_ref);
37
+ rt = { ...r, token, url: `http://127.0.0.1:${r.port}` };
22
38
  } catch {
23
39
  throw new Error('AIWG Cockpit Bridge not found. Start it with `aiwg cockpit` (or `node apps/cockpit/bridge/src/server.mjs`) and retry.');
24
40
  }
@@ -42,14 +58,7 @@ function activate(context) {
42
58
  vscode.commands.registerCommand('aiwg-cockpit.auditIssues', async () => {
43
59
  let rt;
44
60
  try { rt = await ensureRuntime(); } catch (e) { return vscode.window.showWarningMessage(e.message); }
45
- const out = vscode.window.createOutputChannel('AIWG Cockpit');
46
- out.show(true);
47
- out.appendLine('Running contributed action: audit-issues…');
48
- try {
49
- const r = await fetch(`${rt.url}/api/actions/audit-issues/run`, { method: 'POST', headers: { authorization: `Bearer ${rt.token}` } });
50
- const j = await r.json();
51
- out.appendLine(j.output || JSON.stringify(j, null, 2));
52
- } catch (e) { out.appendLine('Error: ' + e.message); }
61
+ vscode.env.openExternal(vscode.Uri.parse(`${rt.url}/?token=${encodeURIComponent(rt.token)}#actions`));
53
62
  }),
54
63
  );
55
64
  }
package/web/src/App.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState, type ReactNode } from 'react';
2
2
  import { useSession } from './useSession';
3
- import { api } from './api';
3
+ import { api, TOKEN } from './api';
4
4
  import type { Approval, Instance, ResponseNeeded } from './types';
5
5
  import { Welcome } from './components/Welcome';
6
6
  import { Inventory } from './components/Inventory';
@@ -37,7 +37,10 @@ interface ChromeStatus {
37
37
  const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
38
38
 
39
39
  export function App() {
40
- const [tab, setTab] = useState<TabId>('welcome');
40
+ const [tab, setTab] = useState<TabId>(() => {
41
+ const hash = window.location.hash.replace(/^#/, '');
42
+ return TABS.some((t) => t.id === hash) ? hash as TabId : 'welcome';
43
+ });
41
44
  const session = useSession();
42
45
  const [composer, setComposer] = useState('');
43
46
  const [chrome, setChrome] = useState<ChromeStatus | null>(null);
@@ -82,6 +85,23 @@ export function App() {
82
85
  return () => { cancelled = true; window.clearInterval(timer); };
83
86
  }, [session.responseNeeded.needed, refreshTick]);
84
87
 
88
+ useEffect(() => {
89
+ if (typeof EventSource === 'undefined' || !TOKEN) return;
90
+ const events = new EventSource(`/api/events?token=${encodeURIComponent(TOKEN)}`);
91
+ events.addEventListener('cockpit.refresh', () => setRefreshTick((t) => t + 1));
92
+ events.onerror = () => undefined;
93
+ return () => events.close();
94
+ }, []);
95
+
96
+ useEffect(() => {
97
+ const onHash = () => {
98
+ const hash = window.location.hash.replace(/^#/, '');
99
+ if (TABS.some((t) => t.id === hash)) setTab(hash as TabId);
100
+ };
101
+ window.addEventListener('hashchange', onHash);
102
+ return () => window.removeEventListener('hashchange', onHash);
103
+ }, []);
104
+
85
105
  // The onboarding primary verb: open the start-session picker (#1640/#1641). The picker
86
106
  // is the single home for both this dashboard verb and the Sessions-tab Start button —
87
107
  // neither launches blind with defaults, neither silently clobbers an attached session,
@@ -144,18 +164,18 @@ export function App() {
144
164
  <main>
145
165
  <Panel id="welcome" tab={tab}><Welcome onStartSession={() => requestStart()} onLaunchInstance={() => setLaunchOpen(true)} goTo={(t) => setTab(t as TabId)} /></Panel>
146
166
  <Panel id="inventory" tab={tab}><Inventory onStartSession={requestStart} onLaunchInstance={() => setLaunchOpen(true)} /></Panel>
147
- <Panel id="running" tab={tab}><Running /></Panel>
167
+ <Panel id="running" tab={tab}><Running refreshTick={refreshTick} /></Panel>
148
168
  {/* Sessions stays mounted so the WebSocket survives tab switches */}
149
169
  <section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" hidden={tab !== 'sessions'}>
150
170
  <Sessions session={session} composer={composer} setComposer={setComposer} onRequestStart={requestStart} />
151
171
  </section>
152
- <Panel id="approvals" tab={tab}><Approvals responses={session.responseNeeded.needed ? [sessionResponse(session)] : []} goSessions={() => setTab('sessions')} /></Panel>
172
+ <Panel id="approvals" tab={tab}><Approvals refreshTick={refreshTick} responses={session.responseNeeded.needed ? [sessionResponse(session)] : []} goSessions={() => setTab('sessions')} /></Panel>
153
173
  <Panel id="explore" tab={tab}><Explore /></Panel>
154
174
  <Panel id="library" tab={tab}>
155
175
  <Library session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
156
176
  </Panel>
157
177
  <Panel id="actions" tab={tab}>
158
- <Actions session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
178
+ <Actions refreshTick={refreshTick} session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
159
179
  </Panel>
160
180
  </main>
161
181
  <StartSessionModal
package/web/src/api.ts CHANGED
@@ -7,7 +7,11 @@ declare global {
7
7
  const TOKEN = (typeof window !== 'undefined' && window.__COCKPIT_TOKEN__) || '';
8
8
 
9
9
  export function apiRaw(path: string, opts: RequestInit = {}): Promise<Response> {
10
- return fetch(path, { ...opts, headers: { ...(opts.headers || {}), authorization: `Bearer ${TOKEN}` } });
10
+ const method = String(opts.method || 'GET').toUpperCase();
11
+ const headers = new Headers(opts.headers);
12
+ headers.set('authorization', `Bearer ${TOKEN}`);
13
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) headers.set('x-cockpit-csrf', TOKEN);
14
+ return fetch(path, { ...opts, headers });
11
15
  }
12
16
 
13
17
  export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
@@ -4,14 +4,14 @@ import type { ContribAction } from '../types';
4
4
  import type { SessionApi } from '../useSession';
5
5
 
6
6
  // Actions INJECT a command into an agentic session — the Cockpit never runs the CLI.
7
- export function Actions({ session, setComposer, goSessions }: { session: SessionApi; setComposer: (v: string) => void; goSessions: () => void }) {
7
+ export function Actions({ refreshTick = 0, session, setComposer, goSessions }: { refreshTick?: number; session: SessionApi; setComposer: (v: string) => void; goSessions: () => void }) {
8
8
  const [actions, setActions] = useState<ContribAction[]>([]);
9
9
  const [err, setErr] = useState('');
10
10
  const [note, setNote] = useState('');
11
11
 
12
12
  useEffect(() => {
13
13
  api<{ actions: ContribAction[] }>('/api/contributions').then((d) => setActions(d.actions)).catch((e) => setErr((e as Error).message));
14
- }, []);
14
+ }, [refreshTick]);
15
15
 
16
16
  const inject = (a: ContribAction) => {
17
17
  let command = a.inject.command;
@@ -3,7 +3,7 @@ import { api } from '../api';
3
3
  import { fmtId } from '../util';
4
4
  import type { Approval, ResponseNeeded } from '../types';
5
5
 
6
- export function Approvals({ responses = [], goSessions }: { responses?: ResponseNeeded[]; goSessions?: () => void }) {
6
+ export function Approvals({ refreshTick = 0, responses = [], goSessions }: { refreshTick?: number; responses?: ResponseNeeded[]; goSessions?: () => void }) {
7
7
  const [items, setItems] = useState<Approval[] | null>(null);
8
8
  const [err, setErr] = useState('');
9
9
 
@@ -11,7 +11,7 @@ export function Approvals({ responses = [], goSessions }: { responses?: Response
11
11
  api<{ approvals: Approval[] }>('/api/approvals?status=pending')
12
12
  .then((d) => { setItems(d.approvals); setErr(''); }).catch((e) => setErr((e as Error).message));
13
13
  }, []);
14
- useEffect(() => { load(); }, [load]);
14
+ useEffect(() => { load(); }, [load, refreshTick]);
15
15
 
16
16
  const decide = (id: string, decision: 'approve' | 'deny') =>
17
17
  api(`/api/approvals/${encodeURIComponent(id)}?decision=${decision}`, { method: 'POST' })
@@ -5,7 +5,7 @@ import type { RunningTask, Cost } from '../types';
5
5
 
6
6
  interface Run { count: number; running: RunningTask[] }
7
7
 
8
- export function Running() {
8
+ export function Running({ refreshTick = 0 }: { refreshTick?: number }) {
9
9
  const [run, setRun] = useState<Run | null>(null);
10
10
  const [cost, setCost] = useState<Cost | null>(null);
11
11
  const [err, setErr] = useState('');
@@ -14,7 +14,7 @@ export function Running() {
14
14
  api<Run>('/api/running').then((d) => { setRun(d); setErr(''); }).catch((e) => setErr((e as Error).message));
15
15
  api<Cost>('/api/cost').then(setCost).catch(() => setCost(null));
16
16
  }, []);
17
- useEffect(() => { load(); }, [load]);
17
+ useEffect(() => { load(); }, [load, refreshTick]);
18
18
 
19
19
  const stop = (t: RunningTask) =>
20
20
  api(`/api/tasks/${encodeURIComponent(t.instance_id)}/${encodeURIComponent(t.task_id)}/cancel`, { method: 'POST' })