@aiwg/cockpit 2026.6.10 → 2026.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/bridge/src/public/index.html +12 -3
- package/bridge/src/server.mjs +182 -25
- package/bridge/src/smoke.mjs +5 -3
- package/desktop/README.md +6 -5
- package/package.json +1 -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.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/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
|
|
@@ -148,7 +148,11 @@
|
|
|
148
148
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
149
149
|
// per-launch token injected by the Bridge for the gated control surface
|
|
150
150
|
const TOKEN = window.__COCKPIT_TOKEN__ || '';
|
|
151
|
-
const api = (u, o = {}) =>
|
|
151
|
+
const api = (u, o = {}) => {
|
|
152
|
+
const method = String(o.method || 'GET').toUpperCase();
|
|
153
|
+
const csrf = ['GET', 'HEAD', 'OPTIONS'].includes(method) ? {} : { 'x-cockpit-csrf': TOKEN };
|
|
154
|
+
return fetch(u, { ...o, headers: { ...(o.headers || {}), ...csrf, authorization: 'Bearer ' + TOKEN } });
|
|
155
|
+
};
|
|
152
156
|
|
|
153
157
|
// --- Inventory ---
|
|
154
158
|
async function loadInventory() {
|
|
@@ -379,7 +383,7 @@
|
|
|
379
383
|
}
|
|
380
384
|
}
|
|
381
385
|
|
|
382
|
-
|
|
386
|
+
function refreshActive() {
|
|
383
387
|
const active = tabs.find((t) => t.getAttribute('aria-selected') === 'true').id;
|
|
384
388
|
if (active === 'tab-inventory') loadInventory();
|
|
385
389
|
else if (active === 'tab-running') loadRunning();
|
|
@@ -387,7 +391,12 @@
|
|
|
387
391
|
else if (active === 'tab-approvals') loadApprovals();
|
|
388
392
|
else if (active === 'tab-actions') loadActions();
|
|
389
393
|
else discover();
|
|
390
|
-
}
|
|
394
|
+
}
|
|
395
|
+
document.getElementById('refresh').addEventListener('click', refreshActive);
|
|
396
|
+
if ('EventSource' in window && TOKEN) {
|
|
397
|
+
const events = new EventSource('/api/events?token=' + encodeURIComponent(TOKEN));
|
|
398
|
+
events.addEventListener('cockpit.refresh', refreshActive);
|
|
399
|
+
}
|
|
391
400
|
|
|
392
401
|
loadInventory();
|
|
393
402
|
</script>
|
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
|
}
|
|
@@ -751,36 +788,129 @@ async function getRunning(executorUrl) {
|
|
|
751
788
|
};
|
|
752
789
|
}
|
|
753
790
|
|
|
791
|
+
function textFromParts(parts) {
|
|
792
|
+
if (!Array.isArray(parts)) return '';
|
|
793
|
+
return parts
|
|
794
|
+
.map((p) => p?.text ?? p?.content ?? p?.value ?? '')
|
|
795
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
796
|
+
.join('\n');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function approvalPromptFromTask(task) {
|
|
800
|
+
const meta = task.metadata ?? {};
|
|
801
|
+
const status = typeof task.status === 'object' ? task.status : {};
|
|
802
|
+
const prompt = [
|
|
803
|
+
meta.hitl_prompt?.prompt,
|
|
804
|
+
meta.hitlPrompt?.prompt,
|
|
805
|
+
meta.approval?.prompt,
|
|
806
|
+
meta.prompt,
|
|
807
|
+
status.message,
|
|
808
|
+
status.prompt,
|
|
809
|
+
textFromParts(task.artifacts?.flatMap((a) => a.parts ?? [])),
|
|
810
|
+
textFromParts(task.history?.at?.(-1)?.parts),
|
|
811
|
+
].find((v) => typeof v === 'string' && v.trim());
|
|
812
|
+
return String(prompt || 'Human input required');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function approvalFromTask(instance, task) {
|
|
816
|
+
const state = taskState(task);
|
|
817
|
+
const meta = task.metadata ?? {};
|
|
818
|
+
const hasHitlPrompt = meta.hitl_prompt || meta.hitlPrompt || meta.approval || meta['hitl-prompt/v1'];
|
|
819
|
+
if (state !== 'input-required' && !hasHitlPrompt) return null;
|
|
820
|
+
const taskId = taskIdOf(task);
|
|
821
|
+
if (!taskId) return null;
|
|
822
|
+
return {
|
|
823
|
+
id: `${instance.id}::${taskId}`,
|
|
824
|
+
instance_id: instance.id,
|
|
825
|
+
task_id: taskId,
|
|
826
|
+
prompt: approvalPromptFromTask(task),
|
|
827
|
+
risk: meta.risk ?? meta.approval?.risk ?? meta.hitl_prompt?.risk ?? 'unknown',
|
|
828
|
+
created_at: task.created_at ?? task.createdAt ?? task.status?.timestamp ?? task.metadata?.created_at,
|
|
829
|
+
status: state === 'input-required' ? 'pending' : state,
|
|
830
|
+
tenant: taskTenantOf(task),
|
|
831
|
+
derived: 'a2a input-required task',
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
754
835
|
/**
|
|
755
|
-
* Pending HITL approvals (the unified approval inbox)
|
|
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.
|
|
836
|
+
* Pending HITL approvals (the unified approval inbox) derived from real A2A
|
|
837
|
+
* `input-required` / `hitl-prompt/v1` task surfaces. The real agentic-sandbox
|
|
838
|
+
* v2 admin router has no approvals queue, so this deliberately does not probe
|
|
839
|
+
* `/admin/approvals`.
|
|
761
840
|
*/
|
|
762
841
|
async function getApprovals(executorUrl, status) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
}
|
|
776
|
-
|
|
842
|
+
const instances = (await getInventory(executorUrl)).instances;
|
|
843
|
+
const approvals = [];
|
|
844
|
+
await Promise.all(
|
|
845
|
+
instances.filter((i) => i.state === 'running').map(async (inst) => {
|
|
846
|
+
let tasks;
|
|
847
|
+
try { tasks = await listInstanceTasks(executorUrl, inst.id); } catch { return; }
|
|
848
|
+
for (const t of tasks) {
|
|
849
|
+
const approval = approvalFromTask(inst, t);
|
|
850
|
+
if (!approval) continue;
|
|
851
|
+
if (status && status !== 'all' && approval.status !== status) continue;
|
|
852
|
+
approvals.push(approval);
|
|
853
|
+
}
|
|
854
|
+
}),
|
|
855
|
+
);
|
|
777
856
|
return {
|
|
778
857
|
source: executorUrl,
|
|
779
858
|
fetched_at: new Date().toISOString(),
|
|
780
|
-
approvals
|
|
859
|
+
approvals,
|
|
860
|
+
derived: 'per-instance A2A input-required tasks',
|
|
781
861
|
};
|
|
782
862
|
}
|
|
783
863
|
|
|
864
|
+
async function respondApproval(executorUrl, approvalId, decision) {
|
|
865
|
+
if (!['approve', 'deny'].includes(decision)) return { status: 400, body: { error: 'decision must be approve|deny' } };
|
|
866
|
+
const [instanceId, taskId] = String(approvalId).split('::');
|
|
867
|
+
if (!instanceId || !taskId) return { status: 400, body: { error: 'invalid_approval_id' } };
|
|
868
|
+
const agentId = await resolveSessionAgentId(executorUrl, instanceId);
|
|
869
|
+
const message = {
|
|
870
|
+
message: {
|
|
871
|
+
messageId: `cockpit-hitl-${Date.now()}`,
|
|
872
|
+
role: 'user',
|
|
873
|
+
taskId,
|
|
874
|
+
contextId: taskId,
|
|
875
|
+
parts: [{ kind: 'text', text: decision }],
|
|
876
|
+
metadata: { hitl_response: { decision }, approval_decision: decision },
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
const response = JSON.stringify({ decision, response: message.message });
|
|
880
|
+
const candidates = unique([agentId, instanceId]).flatMap((id) => [
|
|
881
|
+
{
|
|
882
|
+
target: `${executorUrl}/api/v1/agents/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}:respond`,
|
|
883
|
+
method: 'POST',
|
|
884
|
+
headers: { 'content-type': 'application/json' },
|
|
885
|
+
body: response,
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
target: `${executorUrl}/agents/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}:respond`,
|
|
889
|
+
method: 'POST',
|
|
890
|
+
headers: { 'content-type': 'application/json' },
|
|
891
|
+
body: response,
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
target: `${executorUrl}/api/v1/agents/${encodeURIComponent(id)}/messages:send`,
|
|
895
|
+
method: 'POST',
|
|
896
|
+
headers: { 'content-type': 'application/json' },
|
|
897
|
+
body: JSON.stringify(message),
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
target: `${executorUrl}/agents/${encodeURIComponent(id)}/messages:send`,
|
|
901
|
+
method: 'POST',
|
|
902
|
+
headers: { 'content-type': 'application/json' },
|
|
903
|
+
body: JSON.stringify(message),
|
|
904
|
+
},
|
|
905
|
+
]);
|
|
906
|
+
try {
|
|
907
|
+
const { status, body } = await fetchJsonFirst(candidates);
|
|
908
|
+
return { status, body };
|
|
909
|
+
} catch (e) {
|
|
910
|
+
return { status: 409, body: { error: 'approval_response_failed', detail: String(e?.message ?? e) } };
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
784
914
|
/**
|
|
785
915
|
* Sessions for one instance, each with a direct attach_url. Control plane (this
|
|
786
916
|
* list) goes through the Bridge; the data plane (the pty stream) connects direct
|
|
@@ -864,10 +994,16 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
864
994
|
try {
|
|
865
995
|
// unauthenticated liveness probe (no /api/ prefix) — for the shell to wait on
|
|
866
996
|
if (url.pathname === '/healthz') return json(res, 200, { status: 'ok' });
|
|
997
|
+
if (url.pathname.startsWith('/api/') && !validBrowserOrigin(req)) {
|
|
998
|
+
return json(res, 403, { error: 'forbidden_origin' });
|
|
999
|
+
}
|
|
867
1000
|
// gate the control surface: per-launch bearer token on every /api/ call
|
|
868
1001
|
if (url.pathname.startsWith('/api/') && !authed(req, url, TOKEN)) {
|
|
869
1002
|
return json(res, 401, { error: 'unauthorized', detail: 'missing or invalid cockpit token' });
|
|
870
1003
|
}
|
|
1004
|
+
if (url.pathname.startsWith('/api/') && !validCsrf(req, TOKEN)) {
|
|
1005
|
+
return json(res, 403, { error: 'csrf_required' });
|
|
1006
|
+
}
|
|
871
1007
|
if (url.pathname.startsWith('/api/')) {
|
|
872
1008
|
try {
|
|
873
1009
|
await assertRealExecutor(upstreamUrl, allowMockExecutor);
|
|
@@ -875,6 +1011,21 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
875
1011
|
return json(res, 502, { error: err.code ?? 'executor_refused', message: String(err?.message ?? err) });
|
|
876
1012
|
}
|
|
877
1013
|
}
|
|
1014
|
+
if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
1015
|
+
res.writeHead(200, {
|
|
1016
|
+
'content-type': 'text/event-stream',
|
|
1017
|
+
'cache-control': 'no-cache',
|
|
1018
|
+
connection: 'keep-alive',
|
|
1019
|
+
});
|
|
1020
|
+
const emit = (reason = 'heartbeat') => {
|
|
1021
|
+
res.write(`event: cockpit.refresh\n`);
|
|
1022
|
+
res.write(`data: ${JSON.stringify({ reason, ts: new Date().toISOString() })}\n\n`);
|
|
1023
|
+
};
|
|
1024
|
+
emit('connected');
|
|
1025
|
+
const timer = setInterval(() => emit(), 5_000);
|
|
1026
|
+
req.on('close', () => clearInterval(timer));
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
878
1029
|
if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
|
|
879
1030
|
if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
|
|
880
1031
|
if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
|
|
@@ -1066,8 +1217,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1066
1217
|
// --- approval inbox (UC-009) + cost (UC-010) ---
|
|
1067
1218
|
if (url.pathname === '/api/approvals' && req.method === 'GET')
|
|
1068
1219
|
return json(res, 200, await getApprovals(upstreamUrl, url.searchParams.get('status') || 'pending'));
|
|
1069
|
-
if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST')
|
|
1070
|
-
|
|
1220
|
+
if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST') {
|
|
1221
|
+
const { status, body } = await respondApproval(upstreamUrl, decodeURIComponent(m[1]), url.searchParams.get('decision') || '');
|
|
1222
|
+
return json(res, status, body);
|
|
1223
|
+
}
|
|
1071
1224
|
if (url.pathname === '/api/cost' && req.method === 'GET')
|
|
1072
1225
|
return proxy(res, 'GET', `${upstreamUrl}/admin/cost`);
|
|
1073
1226
|
|
|
@@ -1079,7 +1232,11 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1079
1232
|
// Inject the per-launch token so the same-origin app can call the gated API.
|
|
1080
1233
|
const html = raw.replace('</head>', `<script>window.__COCKPIT_TOKEN__=${JSON.stringify(TOKEN)}</script>\n</head>`);
|
|
1081
1234
|
// never cache the shell — it must always reference the latest hashed bundle
|
|
1082
|
-
res.writeHead(200, {
|
|
1235
|
+
res.writeHead(200, {
|
|
1236
|
+
'content-type': 'text/html; charset=utf-8',
|
|
1237
|
+
'cache-control': 'no-cache',
|
|
1238
|
+
'set-cookie': `cockpit_csrf=${TOKEN}; Path=/; SameSite=Strict`,
|
|
1239
|
+
});
|
|
1083
1240
|
return res.end(html);
|
|
1084
1241
|
}
|
|
1085
1242
|
// static assets from the built web app (e.g. /assets/*.js, *.css)
|
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.11",
|
|
4
4
|
"description": "AIWG Cockpit — UX-first control plane over AIWG + multi-stack agentic sessions. Opt-in, separately published; NOT shipped in the base aiwg npm package (guarded by test/smoke/cockpit-base-footprint.test.js).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
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.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' })
|
|
@@ -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' })
|