@aiwg/cockpit 2026.6.10 → 2026.6.12

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
@@ -381,11 +382,14 @@ Known state as of the 2026-06-19 host live run:
381
382
  roctinam/agentic-sandbox#499 for upstream regression tracking, but it was not
382
383
  reproduced by this isolated host proof.
383
384
  - Docker/container secure bootstrap is fixed from the agentic-sandbox side in
384
- `v2026.6.24`; roctinam/agentic-sandbox#497 is closed. AIWG still needs to
385
- rerun this matrix against that release and record Cockpit-side evidence.
386
- - VM bootstrap/readiness is fixed from the agentic-sandbox side in
387
- `v2026.6.24`; roctinam/agentic-sandbox#498 is closed. AIWG still needs to
388
- rerun this matrix against that release and record Cockpit-side evidence.
385
+ `v2026.6.24`; roctinam/agentic-sandbox#497 is closed. Re-validated Cockpit-side
386
+ against `v2026.6.34`: container matrix PASS.
387
+ - VM bootstrap/readiness is fixed from the agentic-sandbox side via the vsock
388
+ transport line (`v2026.6.31`–`v2026.6.34`); roctinam/agentic-sandbox#498 and
389
+ the #561 transport regression are closed. Re-validated Cockpit-side against
390
+ `v2026.6.34`: VM matrix PASS — provision → vsock enroll → boot-ready → provider
391
+ workload → clean destroy. Evidence:
392
+ `.aiwg/testing/cockpit-vm-vsock-2026-06-27.md/.json`.
389
393
  - Remaining upstream follow-ups are Claude auth-state propagation
390
394
  (roctinam/agentic-sandbox#499) and agent-scoped PTY sessions not appearing in
391
395
  the formal/global session registry (roctinam/agentic-sandbox#500).
@@ -139,7 +139,14 @@
139
139
 
140
140
  // shared: POST/DELETE a control-plane call, then re-run a loader
141
141
  async function control(path, method, reload) {
142
- try { await api(path, { method }); } catch (e) { alert(e.message); }
142
+ try {
143
+ const res = await api(path, { method });
144
+ const body = await res.json().catch(() => ({}));
145
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
146
+ if (body.already_gone) {
147
+ meta.textContent = body.message || 'Instance already removed; inventory refreshed.';
148
+ }
149
+ } catch (e) { alert(e.message); }
143
150
  if (reload) reload();
144
151
  }
145
152
 
@@ -148,7 +155,11 @@
148
155
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
149
156
  // per-launch token injected by the Bridge for the gated control surface
150
157
  const TOKEN = window.__COCKPIT_TOKEN__ || '';
151
- const api = (u, o = {}) => fetch(u, { ...o, headers: { ...(o.headers || {}), authorization: 'Bearer ' + TOKEN } });
158
+ const api = (u, o = {}) => {
159
+ const method = String(o.method || 'GET').toUpperCase();
160
+ const csrf = ['GET', 'HEAD', 'OPTIONS'].includes(method) ? {} : { 'x-cockpit-csrf': TOKEN };
161
+ return fetch(u, { ...o, headers: { ...(o.headers || {}), ...csrf, authorization: 'Bearer ' + TOKEN } });
162
+ };
152
163
 
153
164
  // --- Inventory ---
154
165
  async function loadInventory() {
@@ -379,7 +390,7 @@
379
390
  }
380
391
  }
381
392
 
382
- document.getElementById('refresh').addEventListener('click', () => {
393
+ function refreshActive() {
383
394
  const active = tabs.find((t) => t.getAttribute('aria-selected') === 'true').id;
384
395
  if (active === 'tab-inventory') loadInventory();
385
396
  else if (active === 'tab-running') loadRunning();
@@ -387,7 +398,12 @@
387
398
  else if (active === 'tab-approvals') loadApprovals();
388
399
  else if (active === 'tab-actions') loadActions();
389
400
  else discover();
390
- });
401
+ }
402
+ document.getElementById('refresh').addEventListener('click', refreshActive);
403
+ if ('EventSource' in window && TOKEN) {
404
+ const events = new EventSource('/api/events?token=' + encodeURIComponent(TOKEN));
405
+ events.addEventListener('cockpit.refresh', refreshActive);
406
+ }
391
407
 
392
408
  loadInventory();
393
409
  </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
  }
@@ -332,7 +369,22 @@ async function destroyInstance(upstreamUrl, instanceId) {
332
369
  }
333
370
  return result;
334
371
  }
335
- } catch {
372
+ } catch (err) {
373
+ const message = String(err?.message ?? err);
374
+ if (inst && / -> 404(?:;|$)/.test(message)) {
375
+ return {
376
+ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/destroy`,
377
+ status: 200,
378
+ body: {
379
+ id: instanceId,
380
+ destroyed: instanceId,
381
+ state: 'destroyed',
382
+ result: { state: 'destroyed' },
383
+ already_gone: true,
384
+ message: `Instance ${instanceId} was already removed; inventory refreshed.`,
385
+ },
386
+ };
387
+ }
336
388
  // Current sandbox builds can list Docker rows in admin-v2 inventory while
337
389
  // lifecycle verbs return instance.not_found. Fall through to a dev cleanup.
338
390
  }
@@ -420,7 +472,7 @@ function normalizeRuntimePosture(kind) {
420
472
  label: 'Container / shared kernel',
421
473
  warning: 'Container isolation shares the host kernel.',
422
474
  };
423
- if (runtime === 'vm') return { kind: runtime, isolation: 'strong', label: 'VM / hardware boundary' };
475
+ if (runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') return { kind: 'vm', isolation: 'strong', label: 'VM / hardware boundary' };
424
476
  if (runtime === 'unknown') return { kind: runtime, isolation: 'unknown', label: 'Unknown runtime', warning: 'Runtime metadata was not reported by the sandbox.' };
425
477
  return {
426
478
  kind: runtime,
@@ -473,7 +525,7 @@ function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agen
473
525
  if (!list.length && runtimeKind === 'host') {
474
526
  return [{ mode: 'managed', backend: 'tmux', observe: true, drive: true, replay: false, keyframe: false, available: true, reason: 'agentic-sandbox v1 host session API default' }];
475
527
  }
476
- if (!list.length && ['docker', 'container', 'vm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
528
+ if (!list.length && ['docker', 'container', 'vm', 'qemu', 'kvm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
477
529
  return [{
478
530
  mode: 'managed',
479
531
  backend: 'tmux',
@@ -751,36 +803,129 @@ async function getRunning(executorUrl) {
751
803
  };
752
804
  }
753
805
 
806
+ function textFromParts(parts) {
807
+ if (!Array.isArray(parts)) return '';
808
+ return parts
809
+ .map((p) => p?.text ?? p?.content ?? p?.value ?? '')
810
+ .filter((p) => typeof p === 'string' && p.trim())
811
+ .join('\n');
812
+ }
813
+
814
+ function approvalPromptFromTask(task) {
815
+ const meta = task.metadata ?? {};
816
+ const status = typeof task.status === 'object' ? task.status : {};
817
+ const prompt = [
818
+ meta.hitl_prompt?.prompt,
819
+ meta.hitlPrompt?.prompt,
820
+ meta.approval?.prompt,
821
+ meta.prompt,
822
+ status.message,
823
+ status.prompt,
824
+ textFromParts(task.artifacts?.flatMap((a) => a.parts ?? [])),
825
+ textFromParts(task.history?.at?.(-1)?.parts),
826
+ ].find((v) => typeof v === 'string' && v.trim());
827
+ return String(prompt || 'Human input required');
828
+ }
829
+
830
+ function approvalFromTask(instance, task) {
831
+ const state = taskState(task);
832
+ const meta = task.metadata ?? {};
833
+ const hasHitlPrompt = meta.hitl_prompt || meta.hitlPrompt || meta.approval || meta['hitl-prompt/v1'];
834
+ if (state !== 'input-required' && !hasHitlPrompt) return null;
835
+ const taskId = taskIdOf(task);
836
+ if (!taskId) return null;
837
+ return {
838
+ id: `${instance.id}::${taskId}`,
839
+ instance_id: instance.id,
840
+ task_id: taskId,
841
+ prompt: approvalPromptFromTask(task),
842
+ risk: meta.risk ?? meta.approval?.risk ?? meta.hitl_prompt?.risk ?? 'unknown',
843
+ created_at: task.created_at ?? task.createdAt ?? task.status?.timestamp ?? task.metadata?.created_at,
844
+ status: state === 'input-required' ? 'pending' : state,
845
+ tenant: taskTenantOf(task),
846
+ derived: 'a2a input-required task',
847
+ };
848
+ }
849
+
754
850
  /**
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.
851
+ * Pending HITL approvals (the unified approval inbox) derived from real A2A
852
+ * `input-required` / `hitl-prompt/v1` task surfaces. The real agentic-sandbox
853
+ * v2 admin router has no approvals queue, so this deliberately does not probe
854
+ * `/admin/approvals`.
761
855
  */
762
856
  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
- }
857
+ const instances = (await getInventory(executorUrl)).instances;
858
+ const approvals = [];
859
+ await Promise.all(
860
+ instances.filter((i) => i.state === 'running').map(async (inst) => {
861
+ let tasks;
862
+ try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
863
+ for (const t of tasks) {
864
+ const approval = approvalFromTask(inst, t);
865
+ if (!approval) continue;
866
+ if (status && status !== 'all' && approval.status !== status) continue;
867
+ approvals.push(approval);
868
+ }
869
+ }),
870
+ );
777
871
  return {
778
872
  source: executorUrl,
779
873
  fetched_at: new Date().toISOString(),
780
- approvals: asArrayFromEnvelope(body, ['approvals', 'items', 'data']),
874
+ approvals,
875
+ derived: 'per-instance A2A input-required tasks',
781
876
  };
782
877
  }
783
878
 
879
+ async function respondApproval(executorUrl, approvalId, decision) {
880
+ if (!['approve', 'deny'].includes(decision)) return { status: 400, body: { error: 'decision must be approve|deny' } };
881
+ const [instanceId, taskId] = String(approvalId).split('::');
882
+ if (!instanceId || !taskId) return { status: 400, body: { error: 'invalid_approval_id' } };
883
+ const agentId = await resolveSessionAgentId(executorUrl, instanceId);
884
+ const message = {
885
+ message: {
886
+ messageId: `cockpit-hitl-${Date.now()}`,
887
+ role: 'user',
888
+ taskId,
889
+ contextId: taskId,
890
+ parts: [{ kind: 'text', text: decision }],
891
+ metadata: { hitl_response: { decision }, approval_decision: decision },
892
+ },
893
+ };
894
+ const response = JSON.stringify({ decision, response: message.message });
895
+ const candidates = unique([agentId, instanceId]).flatMap((id) => [
896
+ {
897
+ target: `${executorUrl}/api/v1/agents/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}:respond`,
898
+ method: 'POST',
899
+ headers: { 'content-type': 'application/json' },
900
+ body: response,
901
+ },
902
+ {
903
+ target: `${executorUrl}/agents/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}:respond`,
904
+ method: 'POST',
905
+ headers: { 'content-type': 'application/json' },
906
+ body: response,
907
+ },
908
+ {
909
+ target: `${executorUrl}/api/v1/agents/${encodeURIComponent(id)}/messages:send`,
910
+ method: 'POST',
911
+ headers: { 'content-type': 'application/json' },
912
+ body: JSON.stringify(message),
913
+ },
914
+ {
915
+ target: `${executorUrl}/agents/${encodeURIComponent(id)}/messages:send`,
916
+ method: 'POST',
917
+ headers: { 'content-type': 'application/json' },
918
+ body: JSON.stringify(message),
919
+ },
920
+ ]);
921
+ try {
922
+ const { status, body } = await fetchJsonFirst(candidates);
923
+ return { status, body };
924
+ } catch (e) {
925
+ return { status: 409, body: { error: 'approval_response_failed', detail: String(e?.message ?? e) } };
926
+ }
927
+ }
928
+
784
929
  /**
785
930
  * Sessions for one instance, each with a direct attach_url. Control plane (this
786
931
  * list) goes through the Bridge; the data plane (the pty stream) connects direct
@@ -864,10 +1009,16 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
864
1009
  try {
865
1010
  // unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
866
1011
  if (url.pathname === '/healthz') return json(res, 200, { status: 'ok' });
1012
+ if (url.pathname.startsWith('/api/') && !validBrowserOrigin(req)) {
1013
+ return json(res, 403, { error: 'forbidden_origin' });
1014
+ }
867
1015
  // gate the control surface: per-launch bearer token on every /api/ call
868
1016
  if (url.pathname.startsWith('/api/') && !authed(req, url, TOKEN)) {
869
1017
  return json(res, 401, { error: 'unauthorized', detail: 'missing or invalid cockpit token' });
870
1018
  }
1019
+ if (url.pathname.startsWith('/api/') && !validCsrf(req, TOKEN)) {
1020
+ return json(res, 403, { error: 'csrf_required' });
1021
+ }
871
1022
  if (url.pathname.startsWith('/api/')) {
872
1023
  try {
873
1024
  await assertRealExecutor(upstreamUrl, allowMockExecutor);
@@ -875,6 +1026,21 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
875
1026
  return json(res, 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err) });
876
1027
  }
877
1028
  }
1029
+ if (url.pathname === '/api/events' && req.method === 'GET') {
1030
+ res.writeHead(200, {
1031
+ 'content-type': 'text/event-stream',
1032
+ 'cache-control': 'no-cache',
1033
+ connection: 'keep-alive',
1034
+ });
1035
+ const emit = (reason = 'heartbeat') => {
1036
+ res.write(`event: cockpit.refresh\n`);
1037
+ res.write(`data: ${JSON.stringify({ reason, ts: new Date().toISOString() })}\n\n`);
1038
+ };
1039
+ emit('connected');
1040
+ const timer = setInterval(() => emit(), 5_000);
1041
+ req.on('close', () => clearInterval(timer));
1042
+ return;
1043
+ }
878
1044
  if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
879
1045
  if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
880
1046
  if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
@@ -1015,7 +1181,6 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1015
1181
  session_class: mode || 'managed',
1016
1182
  command: 'bash',
1017
1183
  args: ['-l'],
1018
- working_dir: '/root',
1019
1184
  }),
1020
1185
  },
1021
1186
  {
@@ -1066,8 +1231,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1066
1231
  // --- approval inbox (UC-009) + cost (UC-010) ---
1067
1232
  if (url.pathname === '/api/approvals' && req.method === 'GET')
1068
1233
  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') || '')}`);
1234
+ if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST') {
1235
+ const { status, body } = await respondApproval(upstreamUrl, decodeURIComponent(m[1]), url.searchParams.get('decision') || '');
1236
+ return json(res, status, body);
1237
+ }
1071
1238
  if (url.pathname === '/api/cost' && req.method === 'GET')
1072
1239
  return proxy(res, 'GET', `${upstreamUrl}/admin/cost`);
1073
1240
 
@@ -1079,7 +1246,11 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1079
1246
  // Inject the per-launch token so the same-origin app can call the gated API.
1080
1247
  const html = raw.replace('</head>', `<script>window.__COCKPIT_TOKEN__=${JSON.stringify(TOKEN)}</script>\n</head>`);
1081
1248
  // 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' });
1249
+ res.writeHead(200, {
1250
+ 'content-type': 'text/html; charset=utf-8',
1251
+ 'cache-control': 'no-cache',
1252
+ 'set-cookie': `cockpit_csrf=${TOKEN}; Path=/; SameSite=Strict`,
1253
+ });
1083
1254
  return res.end(html);
1084
1255
  }
1085
1256
  // 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.12",
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",
@@ -39,6 +39,7 @@
39
39
  "scripts": {
40
40
  "start:bridge": "node bridge/src/server.mjs",
41
41
  "dev": "bash scripts/cockpit-dev.sh",
42
+ "up": "bash scripts/cockpit-up.sh",
42
43
  "build:web": "npm --prefix web install --no-audit --no-fund && npm --prefix web run build",
43
44
  "pack:dry": "npm pack --dry-run",
44
45
  "publish:dry": "npm publish --dry-run --access public",
@@ -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
  }
@@ -195,6 +195,30 @@ describe('App shell (rendered DOM)', () => {
195
195
  expect(screen.getByText('Destination')).toBeTruthy();
196
196
  });
197
197
 
198
+ it('treats stale destroy 404 responses as already removed in Inventory (#1660)', async () => {
199
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
200
+ const inventory = { instances: [instance('ghost-vm-1', 'vm', 'QEMU Codex')], count: 1, fetched_at: new Date().toISOString() };
201
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
202
+ const url = String(input);
203
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
204
+ if (url.includes('/api/inventory')) return jsonResponse(inventory);
205
+ if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
206
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
207
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
208
+ if (url.includes('/api/instances/ghost-vm-1') && init?.method === 'DELETE') {
209
+ return jsonResponse({ destroyed: 'ghost-vm-1', already_gone: true, message: 'Instance ghost-vm-1 was already removed; inventory refreshed.' });
210
+ }
211
+ return jsonResponse({});
212
+ }) as typeof fetch;
213
+
214
+ render(<App />);
215
+ fireEvent.click(screen.getByRole('tab', { name: 'Inventory' }));
216
+ fireEvent.click(await screen.findByRole('button', { name: /destroy instance ghost-vm-1/i }));
217
+
218
+ expect((await screen.findByRole('status')).textContent).toMatch(/already removed; inventory refreshed/i);
219
+ expect(screen.queryByText(/action failed/i)).toBeNull();
220
+ });
221
+
198
222
  it('each tab has a matching labelled tabpanel (controls/labelledby pairing)', () => {
199
223
  render(<App />);
200
224
  for (const tab of screen.getAllByRole('tab')) {
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' })
@@ -9,6 +9,7 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
9
9
  const [data, setData] = useState<Inv | null>(null);
10
10
  const [err, setErr] = useState('');
11
11
  const [actionErr, setActionErr] = useState('');
12
+ const [actionMsg, setActionMsg] = useState('');
12
13
 
13
14
  const load = useCallback(() => {
14
15
  api<Inv>('/api/inventory').then((d) => { setData(d); setErr(''); }).catch((e) => setErr((e as Error).message));
@@ -16,7 +17,16 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
16
17
  useEffect(() => { load(); }, [load]);
17
18
 
18
19
  const control = (path: string, method: string) =>
19
- api(path, { method }).then(() => { setActionErr(''); load(); }).catch((e) => setActionErr((e as Error).message));
20
+ api<{ already_gone?: boolean; message?: string }>(path, { method })
21
+ .then((result) => {
22
+ setActionErr('');
23
+ setActionMsg(result.already_gone ? (result.message ?? 'Instance already removed; inventory refreshed.') : '');
24
+ load();
25
+ })
26
+ .catch((e) => {
27
+ setActionMsg('');
28
+ setActionErr((e as Error).message);
29
+ });
20
30
 
21
31
  if (err) return <p className="err">Could not load inventory: {err}</p>;
22
32
  if (!data) return <p className="empty">Loading…</p>;
@@ -40,6 +50,7 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
40
50
  {onLaunchInstance && <button className="cta" onClick={onLaunchInstance}>+ New instance + session</button>}
41
51
  </div>
42
52
  {actionErr && <p className="err">Action failed: {actionErr}</p>}
53
+ {actionMsg && <p className="hint" role="status">{actionMsg}</p>}
43
54
  <table>
44
55
  <caption>Available instance deployments</caption>
45
56
  <thead>
@@ -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' })