@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 +16 -12
- package/bridge/src/public/index.html +20 -4
- package/bridge/src/server.mjs +200 -29
- package/bridge/src/smoke.mjs +5 -3
- package/desktop/README.md +6 -5
- package/package.json +2 -1
- package/runtime-docs/README.md +16 -8
- package/shell-core/keychain.mjs +77 -0
- package/shell-core/runtime.mjs +6 -3
- package/vscode/README.md +3 -3
- package/vscode/extension.js +18 -9
- package/web/src/App.test.tsx +24 -0
- package/web/src/App.tsx +25 -5
- package/web/src/api.ts +5 -1
- package/web/src/components/Actions.tsx +2 -2
- package/web/src/components/Approvals.tsx +2 -2
- package/web/src/components/Inventory.tsx +12 -1
- package/web/src/components/Running.tsx +2 -2
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 (
|
|
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 (`
|
|
321
|
-
`matrix container`, `matrix
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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.
|
|
385
|
-
|
|
386
|
-
- VM bootstrap/readiness is fixed from the agentic-sandbox side
|
|
387
|
-
`v2026.6.
|
|
388
|
-
|
|
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 {
|
|
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) => ({ '&': '&', '<': '<', '>': '>' }[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 = {}) =>
|
|
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
|
-
|
|
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>
|
package/bridge/src/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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)
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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)
|
package/bridge/src/smoke.mjs
CHANGED
|
@@ -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.
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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`).
|
|
42
|
-
|
|
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.
|
|
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",
|
package/runtime-docs/README.md
CHANGED
|
@@ -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` | `{
|
|
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.
|
|
23
|
-
3.
|
|
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
|
-
-
|
|
28
|
-
|
|
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
|
-
-
|
|
32
|
-
|
|
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
|
+
}
|
package/shell-core/runtime.mjs
CHANGED
|
@@ -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
|
-
// {
|
|
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
|
-
|
|
16
|
-
|
|
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** |
|
|
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
|
|
package/vscode/extension.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.test.tsx
CHANGED
|
@@ -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>(
|
|
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
|
-
|
|
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
|
|
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' })
|