@aiwg/cockpit 2026.7.10 → 2026.7.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 +80 -12
- package/bridge/src/server.mjs +418 -31
- package/bridge/src/smoke.mjs +30 -4
- package/package.json +2 -2
- package/runtime-docs/README.md +3 -2
- package/shell-core/keychain.mjs +35 -5
- package/vscode/extension.js +13 -26
- package/vscode/smoke.mjs +47 -0
- package/web/src/App.test.tsx +119 -1
- package/web/src/App.tsx +58 -27
- package/web/src/components/Actions.tsx +1 -1
- package/web/src/components/Inventory.tsx +61 -14
- package/web/src/components/LaunchInstanceModal.test.tsx +1 -0
- package/web/src/components/LaunchInstanceModal.tsx +4 -1
- package/web/src/components/Library.tsx +1 -1
- package/web/src/components/Sessions.test.tsx +254 -13
- package/web/src/components/Sessions.tsx +243 -66
- package/web/src/components/StartSessionModal.test.tsx +27 -4
- package/web/src/components/StartSessionModal.tsx +22 -4
- package/web/src/components/Welcome.tsx +11 -10
- package/web/src/sessionMonitor.test.tsx +85 -0
- package/web/src/sessionMonitor.ts +72 -0
- package/web/src/sessionRegistry.test.ts +112 -0
- package/web/src/sessionRegistry.ts +192 -0
- package/web/src/styles.css +20 -2
- package/web/src/types.ts +23 -1
- package/web/src/useSession.test.tsx +275 -1
- package/web/src/useSession.ts +362 -181
- package/web/src/util.ts +8 -1
package/bridge/src/server.mjs
CHANGED
|
@@ -357,19 +357,45 @@ async function proxy(res, method, target) {
|
|
|
357
357
|
return json(res, r.status, body);
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
|
|
360
|
+
const SAFE_FALLBACK_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
361
|
+
|
|
362
|
+
function isAbortError(err) {
|
|
363
|
+
return err?.name === 'AbortError' || /aborted|abort/i.test(String(err?.message ?? err));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isConnectionRefusedError(err) {
|
|
367
|
+
const text = [
|
|
368
|
+
err?.code,
|
|
369
|
+
err?.cause?.code,
|
|
370
|
+
err?.message,
|
|
371
|
+
err?.cause?.message,
|
|
372
|
+
].filter(Boolean).join(' ');
|
|
373
|
+
return /ECONNREFUSED|connection refused/i.test(text);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption, timeoutMs = 0 } = {}) {
|
|
361
377
|
const failures = [];
|
|
362
378
|
for (const candidate of candidates) {
|
|
363
379
|
const target = typeof candidate === 'string' ? candidate : candidate.target;
|
|
364
380
|
const requestMethod = typeof candidate === 'string' ? method : candidate.method ?? method;
|
|
365
381
|
const requestHeaders = typeof candidate === 'string' ? headers : candidate.headers ?? headers;
|
|
366
382
|
const requestBody = typeof candidate === 'string' ? requestBodyOption : candidate.body ?? requestBodyOption;
|
|
383
|
+
const controller = timeoutMs > 0 ? new AbortController() : null;
|
|
384
|
+
const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
367
385
|
let r;
|
|
368
386
|
try {
|
|
369
|
-
r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody });
|
|
387
|
+
r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
|
|
370
388
|
} catch (err) {
|
|
371
|
-
|
|
389
|
+
const failure = isAbortError(err) && timeoutMs > 0
|
|
390
|
+
? `${target} -> timeout after ${timeoutMs}ms`
|
|
391
|
+
: `${target} -> ${String(err?.message ?? err)}`;
|
|
392
|
+
failures.push(failure);
|
|
393
|
+
const safeToTryNext = SAFE_FALLBACK_METHODS.has(String(requestMethod).toUpperCase())
|
|
394
|
+
|| isConnectionRefusedError(err);
|
|
395
|
+
if (!safeToTryNext) throw new Error(failures.join('; '));
|
|
372
396
|
continue;
|
|
397
|
+
} finally {
|
|
398
|
+
if (timeout) clearTimeout(timeout);
|
|
373
399
|
}
|
|
374
400
|
const responseBody = await r.json().catch(() => ({}));
|
|
375
401
|
if (r.ok) return { target, status: r.status, body: responseBody };
|
|
@@ -576,6 +602,138 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
576
602
|
};
|
|
577
603
|
}
|
|
578
604
|
|
|
605
|
+
// #1778: VM-runtime counterpart of `docker exec <ctr> agent-reconnect`. Sandbox
|
|
606
|
+
// VM images bake qemu-guest-agent ("essential for virsh exec") and agent-rs
|
|
607
|
+
// handles SIGHUP as reconnect-in-place on every runtime, so delivering
|
|
608
|
+
// `pkill -HUP -x agent-client` through the libvirt guest-agent channel makes
|
|
609
|
+
// the agent re-register without touching the VM. Session survival is
|
|
610
|
+
// version-conditional: agentic-sandbox 2026.7.8+ agents preserve all sessions
|
|
611
|
+
// across reconnect; older agents preserve only detached-tmux sessions
|
|
612
|
+
// (agentic-sandbox#634).
|
|
613
|
+
async function signalVmAgentReconnect(domain) {
|
|
614
|
+
const execRaw = await spawnCollect('virsh', ['qemu-agent-command', domain, JSON.stringify({
|
|
615
|
+
execute: 'guest-exec',
|
|
616
|
+
arguments: { path: '/bin/sh', arg: ['-c', 'pkill -HUP -x agent-client'], 'capture-output': true },
|
|
617
|
+
})]);
|
|
618
|
+
const pid = JSON.parse(String(execRaw))?.return?.pid;
|
|
619
|
+
if (!Number.isInteger(pid)) throw new Error(`guest-exec returned no pid: ${String(execRaw).trim()}`);
|
|
620
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
621
|
+
const statusRaw = await spawnCollect('virsh', ['qemu-agent-command', domain, JSON.stringify({
|
|
622
|
+
execute: 'guest-exec-status',
|
|
623
|
+
arguments: { pid },
|
|
624
|
+
})]);
|
|
625
|
+
const status = JSON.parse(String(statusRaw))?.return;
|
|
626
|
+
if (status?.exited) return status.exitcode ?? 0;
|
|
627
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
628
|
+
}
|
|
629
|
+
// The signal command was handed to the guest; slow exec-status reporting is
|
|
630
|
+
// not a delivery failure.
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const VM_RUNTIME_KINDS = ['vm', 'qemu', 'kvm'];
|
|
635
|
+
|
|
636
|
+
async function reconnectInstance(upstreamUrl, instanceId) {
|
|
637
|
+
const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
|
|
638
|
+
const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
|
|
639
|
+
const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
|
|
640
|
+
const dockerName = inst?.launch_context?.name;
|
|
641
|
+
const candidates = [
|
|
642
|
+
{ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
|
|
643
|
+
{ target: `${upstreamUrl}/admin/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
|
|
644
|
+
{ target: `${upstreamUrl}/api/v1/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
|
|
645
|
+
];
|
|
646
|
+
try {
|
|
647
|
+
const result = await fetchJsonFirst(candidates, { timeoutMs: 5_000 });
|
|
648
|
+
if (result.status < 400) return result;
|
|
649
|
+
} catch {
|
|
650
|
+
// agentic-sandbox v2026.7.6 still exposes the container reconnect as an
|
|
651
|
+
// in-image helper, not an HTTP endpoint. Fall through to the local-dev path.
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (['docker', 'container'].includes(runtime) && dockerName) {
|
|
655
|
+
try {
|
|
656
|
+
const output = await spawnCollect('docker', ['exec', dockerName, 'agent-reconnect']);
|
|
657
|
+
return {
|
|
658
|
+
target: `docker exec ${dockerName} agent-reconnect`,
|
|
659
|
+
status: 202,
|
|
660
|
+
body: {
|
|
661
|
+
id: instanceId,
|
|
662
|
+
runtime,
|
|
663
|
+
docker_name: dockerName,
|
|
664
|
+
state: 'reconnecting',
|
|
665
|
+
message: `Reconnect requested for ${dockerName}; inventory will refresh as the agent re-registers.`,
|
|
666
|
+
output: String(output).trim(),
|
|
667
|
+
fallback: 'docker-agent-reconnect',
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
} catch (err) {
|
|
671
|
+
return {
|
|
672
|
+
target: `docker exec ${dockerName} agent-reconnect`,
|
|
673
|
+
status: 502,
|
|
674
|
+
body: {
|
|
675
|
+
error: 'reconnect_failed',
|
|
676
|
+
message: `Could not run agent-reconnect in ${dockerName}. Repull/rebuild the agent image if it predates agentic-sandbox v2026.7.5.`,
|
|
677
|
+
detail: String(err?.message ?? err),
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (VM_RUNTIME_KINDS.includes(runtime)) {
|
|
684
|
+
// For VM instances the agent_id doubles as the libvirt domain name
|
|
685
|
+
// (agentic-sandbox provision-vm.sh registers agent_id = $vm_name).
|
|
686
|
+
const domain = dockerName ?? inst?.name ?? String(instanceId);
|
|
687
|
+
const target = `virsh qemu-agent-command ${domain} guest-exec pkill -HUP -x agent-client`;
|
|
688
|
+
try {
|
|
689
|
+
const exitcode = await signalVmAgentReconnect(domain);
|
|
690
|
+
if (exitcode === 0) {
|
|
691
|
+
return {
|
|
692
|
+
target,
|
|
693
|
+
status: 202,
|
|
694
|
+
body: {
|
|
695
|
+
id: instanceId,
|
|
696
|
+
runtime,
|
|
697
|
+
vm_domain: domain,
|
|
698
|
+
state: 'reconnecting',
|
|
699
|
+
message: `Reconnect requested for VM ${domain}; inventory will refresh as the agent re-registers. Sessions survive reconnect on agentic-sandbox 2026.7.8+ agents; older agents preserve only detached tmux sessions (agentic-sandbox#634).`,
|
|
700
|
+
fallback: 'virsh-guest-agent-sighup',
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
target,
|
|
706
|
+
status: 502,
|
|
707
|
+
body: {
|
|
708
|
+
error: 'reconnect_failed',
|
|
709
|
+
message: `No running agent-client process found inside VM ${domain}. Restart the agent service in the guest (systemctl restart agent-client) or reprovision the VM.`,
|
|
710
|
+
exitcode,
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
} catch (err) {
|
|
714
|
+
return {
|
|
715
|
+
target,
|
|
716
|
+
status: 502,
|
|
717
|
+
body: {
|
|
718
|
+
error: 'reconnect_failed',
|
|
719
|
+
message: `Could not signal agent-client in VM ${domain} via qemu-guest-agent. The bridge host needs virsh access to the libvirt domain and the guest-agent channel must be up (agentic-sandbox#633).`,
|
|
720
|
+
detail: String(err?.message ?? err),
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`,
|
|
728
|
+
status: 409,
|
|
729
|
+
body: {
|
|
730
|
+
error: 'reconnect_unavailable',
|
|
731
|
+
message: 'Reconnect is available for Docker/container instances (docker exec) and VM instances (qemu-guest-agent). For host-runtime agents, signal the agent directly: pkill -HUP -x agent-client.',
|
|
732
|
+
runtime: runtime || 'unknown',
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
579
737
|
function asArrayFromEnvelope(body, keys) {
|
|
580
738
|
if (Array.isArray(body)) return body;
|
|
581
739
|
if (!body || typeof body !== 'object') return [];
|
|
@@ -590,10 +748,22 @@ function asArrayFromEnvelope(body, keys) {
|
|
|
590
748
|
return [];
|
|
591
749
|
}
|
|
592
750
|
|
|
751
|
+
const AGENT_ID_CACHE_TTL_MS = Number(process.env.AIWG_COCKPIT_AGENT_CACHE_TTL_MS ?? 5_000);
|
|
752
|
+
const agentListCache = new Map();
|
|
753
|
+
|
|
754
|
+
async function getAgentList(executorUrl) {
|
|
755
|
+
const cached = agentListCache.get(executorUrl);
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
if (cached && cached.expiresAt > now) return cached.agents;
|
|
758
|
+
const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
|
|
759
|
+
const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
760
|
+
agentListCache.set(executorUrl, { agents, expiresAt: now + AGENT_ID_CACHE_TTL_MS });
|
|
761
|
+
return agents;
|
|
762
|
+
}
|
|
763
|
+
|
|
593
764
|
async function resolveSessionAgentId(executorUrl, instanceId) {
|
|
594
765
|
try {
|
|
595
|
-
const
|
|
596
|
-
const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
766
|
+
const agents = await getAgentList(executorUrl);
|
|
597
767
|
const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(instanceId));
|
|
598
768
|
return agent?.id ?? agent?.agent_id ?? agent?.agentId ?? instanceId;
|
|
599
769
|
} catch {
|
|
@@ -687,7 +857,18 @@ function normalizeTransport(posture) {
|
|
|
687
857
|
function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agentReady = false) {
|
|
688
858
|
const list = Array.isArray(backends) ? backends : [];
|
|
689
859
|
if (!list.length && runtimeKind === 'host') {
|
|
690
|
-
return [{
|
|
860
|
+
return [{
|
|
861
|
+
mode: 'managed',
|
|
862
|
+
backend: 'tmux',
|
|
863
|
+
observe: true,
|
|
864
|
+
drive: true,
|
|
865
|
+
replay: false,
|
|
866
|
+
keyframe: false,
|
|
867
|
+
available: agentReady,
|
|
868
|
+
reason: agentReady
|
|
869
|
+
? 'agentic-sandbox v1 host session API default'
|
|
870
|
+
: 'host runtime is provisioned but the host agent has not registered; PTY sessions are not ready',
|
|
871
|
+
}];
|
|
691
872
|
}
|
|
692
873
|
if (!list.length && ['docker', 'container', 'vm', 'qemu', 'kvm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
|
|
693
874
|
return [{
|
|
@@ -751,6 +932,53 @@ function normalizeInstance(executorUrl, i) {
|
|
|
751
932
|
};
|
|
752
933
|
}
|
|
753
934
|
|
|
935
|
+
function defaultSessionLaunch(instance) {
|
|
936
|
+
const runtime = String(instance?.runtime_posture?.kind ?? instance?.runtime ?? '').toLowerCase();
|
|
937
|
+
if (runtime === 'host') {
|
|
938
|
+
// Honor the executor-reported cwd — it is valid on the target host, including
|
|
939
|
+
// remote worker hosts whose filesystem does not mirror the Bridge machine.
|
|
940
|
+
// Fall back to the operator home only when the executor reports no cwd;
|
|
941
|
+
// hardcoding homedir() would resolve the Bridge's local home and break
|
|
942
|
+
// sessions on any host runtime that is not the Bridge machine itself.
|
|
943
|
+
return {
|
|
944
|
+
command: 'bash',
|
|
945
|
+
args: ['-l'],
|
|
946
|
+
working_dir: instance?.launch_context?.cwd ?? homedir(),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
|
|
950
|
+
const home = runtime === 'container' || runtime === 'docker' ? '/root' : '/home/agent';
|
|
951
|
+
return {
|
|
952
|
+
command: '/bin/bash',
|
|
953
|
+
args: ['-lc', `cd ${shellSingleQuote(home)} && exec /bin/bash -l`],
|
|
954
|
+
working_dir: home,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
command: 'bash',
|
|
959
|
+
args: ['-l'],
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Deterministic prefix + per-request nonce. The name is IDENTICAL across this
|
|
964
|
+
// request's fallback candidates (so a timed-out-but-created session is
|
|
965
|
+
// recoverable by name, and the executor's 409-by-name guard dedupes candidate
|
|
966
|
+
// retries) but UNIQUE across requests — operators can hold multiple concurrent
|
|
967
|
+
// sessions per (instance, mode, backend). A fully canonical name here silently
|
|
968
|
+
// reused the first session on every subsequent "New session" click.
|
|
969
|
+
function sessionNameFor(instanceId, { mode = 'managed', backend = 'tmux' } = {}) {
|
|
970
|
+
const slug = (value) => String(value || 'default')
|
|
971
|
+
.toLowerCase()
|
|
972
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
973
|
+
.replace(/^-+|-+$/g, '')
|
|
974
|
+
.slice(0, 36) || 'default';
|
|
975
|
+
return `cockpit-${slug(instanceId)}-${slug(mode)}-${slug(backend)}-${randomBytes(3).toString('hex')}`;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function shellSingleQuote(value) {
|
|
979
|
+
return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
|
|
980
|
+
}
|
|
981
|
+
|
|
754
982
|
function runtimeExtensionFromCard(card) {
|
|
755
983
|
const extensions = card?.capabilities?.extensions;
|
|
756
984
|
if (!Array.isArray(extensions)) return null;
|
|
@@ -780,8 +1008,7 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
|
|
|
780
1008
|
|
|
781
1009
|
async function getRegisteredAgents(executorUrl) {
|
|
782
1010
|
try {
|
|
783
|
-
|
|
784
|
-
return asArrayFromEnvelope(body, ['agents', 'items', 'data']);
|
|
1011
|
+
return await getAgentList(executorUrl);
|
|
785
1012
|
} catch {
|
|
786
1013
|
return [];
|
|
787
1014
|
}
|
|
@@ -1183,8 +1410,8 @@ async function getSessionEventRows(executorUrl, instances) {
|
|
|
1183
1410
|
agent_id: session.agent_id,
|
|
1184
1411
|
state: session.state ?? session.status ?? session.session_state ?? 'available',
|
|
1185
1412
|
role_policy: session.role_policy,
|
|
1186
|
-
|
|
1187
|
-
|
|
1413
|
+
session_backend: session.session_backend,
|
|
1414
|
+
session_class: session.session_class,
|
|
1188
1415
|
});
|
|
1189
1416
|
}
|
|
1190
1417
|
}));
|
|
@@ -1211,7 +1438,7 @@ async function getEventSnapshot(executorUrl) {
|
|
|
1211
1438
|
events.push({ id: `approval:${approval.id}`, type: 'hitl.approval', source: 'a2a', subject: approval.task_id ?? approval.id, state: approval.status, severity: approval.risk, ts, ref: { instance_id: approval.instance_id, approval_id: approval.id } });
|
|
1212
1439
|
}
|
|
1213
1440
|
for (const session of sessions ?? []) {
|
|
1214
|
-
events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id,
|
|
1441
|
+
events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id, session_backend: session.session_backend, session_class: session.session_class, role_policy: session.role_policy } });
|
|
1215
1442
|
}
|
|
1216
1443
|
for (const mission of missions.missions ?? []) {
|
|
1217
1444
|
events.push({ id: `mission:${mission.id}`, type: 'mission.lifecycle', source: mission.source ?? 'aiwg-mc', subject: mission.id, state: mission.status, ts, ref: { session_id: mission.session_id, mission_id: mission.id, ralph_loop_id: mission.ralph_loop_id } });
|
|
@@ -1284,6 +1511,54 @@ async function getSessions(executorUrl, instanceId) {
|
|
|
1284
1511
|
`${executorUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
1285
1512
|
]));
|
|
1286
1513
|
const sessions = asArrayFromEnvelope(body, ['sessions', 'items', 'data']);
|
|
1514
|
+
return normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId });
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function normalizeScreenSnapshot(body, { instanceId, sessionId, source }) {
|
|
1518
|
+
const text = String(body?.text ?? body?.snapshot ?? body?.screen ?? body?.content ?? '');
|
|
1519
|
+
const rawLines = Array.isArray(body?.lines) ? body.lines : text.replace(/\r/g, '\n').split('\n');
|
|
1520
|
+
return {
|
|
1521
|
+
instance_id: instanceId,
|
|
1522
|
+
session_id: sessionId,
|
|
1523
|
+
text,
|
|
1524
|
+
lines: rawLines.map((line) => String(line)).filter(Boolean).slice(-80),
|
|
1525
|
+
seq: body?.seq ?? body?.sequence ?? body?.anchor_sequence ?? body?.anchorSequence ?? null,
|
|
1526
|
+
fetched_at: new Date().toISOString(),
|
|
1527
|
+
source,
|
|
1528
|
+
snapshot_format: body?.snapshot_format ?? body?.snapshotFormat ?? body?.format ?? 'text/plain',
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async function getSessionScreen(executorUrl, instanceId, sessionId) {
|
|
1533
|
+
const sessionAgentId = await resolveSessionAgentId(executorUrl, instanceId);
|
|
1534
|
+
const agentIds = unique([sessionAgentId, instanceId]);
|
|
1535
|
+
const paths = agentIds.flatMap((agentId) => {
|
|
1536
|
+
const encodedAgent = encodeURIComponent(agentId);
|
|
1537
|
+
const encodedSession = encodeURIComponent(sessionId);
|
|
1538
|
+
return [
|
|
1539
|
+
`${executorUrl}/api/v1/agents/${encodedAgent}/sessions/${encodedSession}/screen`,
|
|
1540
|
+
`${executorUrl}/api/v1/agents/${encodedAgent}/sessions/${encodedSession}/screen-state`,
|
|
1541
|
+
`${executorUrl}/agents/${encodedAgent}/sessions/${encodedSession}/screen`,
|
|
1542
|
+
`${executorUrl}/agents/${encodedAgent}/sessions/${encodedSession}/screen-state`,
|
|
1543
|
+
];
|
|
1544
|
+
});
|
|
1545
|
+
try {
|
|
1546
|
+
const { body, target, status } = await fetchJsonFirst(paths);
|
|
1547
|
+
return { status, body: normalizeScreenSnapshot(body, { instanceId, sessionId, source: target }) };
|
|
1548
|
+
} catch (e) {
|
|
1549
|
+
return {
|
|
1550
|
+
status: 404,
|
|
1551
|
+
body: {
|
|
1552
|
+
error: 'session_screen_unavailable',
|
|
1553
|
+
instance_id: instanceId,
|
|
1554
|
+
session_id: sessionId,
|
|
1555
|
+
detail: String(e?.message ?? e),
|
|
1556
|
+
},
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
export function normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId = instanceId }) {
|
|
1287
1562
|
const wsBase = executorUrl.replace(/^http/i, 'ws');
|
|
1288
1563
|
const normalizeAttachUrl = (s, sessionId) => {
|
|
1289
1564
|
const explicit = s.attach_url ?? s.attachUrl;
|
|
@@ -1304,26 +1579,83 @@ async function getSessions(executorUrl, instanceId) {
|
|
|
1304
1579
|
// agent name is only needed for the session-list FETCH, not the attach path.
|
|
1305
1580
|
return `${wsBase}/agents/${encodeURIComponent(instanceId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
|
|
1306
1581
|
};
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1582
|
+
const sessionAliases = (entry, sessionId) => {
|
|
1583
|
+
const aliases = [`session:${sessionId}`];
|
|
1584
|
+
const commandId = entry.command_id ?? entry.commandId;
|
|
1585
|
+
if (commandId) aliases.push(`command:${commandId}`);
|
|
1586
|
+
return aliases;
|
|
1587
|
+
};
|
|
1588
|
+
const fallbackScore = (entry) => {
|
|
1589
|
+
const sessionId = String(entry.id ?? entry.session_id ?? entry.sessionId ?? '');
|
|
1590
|
+
const commandId = String(entry.command_id ?? entry.commandId ?? '');
|
|
1591
|
+
const id = String(entry.id ?? '');
|
|
1592
|
+
const name = String(entry.session_name ?? entry.sessionName ?? '');
|
|
1593
|
+
const command = String(entry.command ?? '').trim();
|
|
1594
|
+
let score = 0;
|
|
1595
|
+
if (id && commandId && id === commandId) score += 2;
|
|
1596
|
+
if (name && (name === sessionId || name === commandId)) score += 2;
|
|
1597
|
+
if (/^\/?bin\/bash\s+-l$/.test(command)) score += 1;
|
|
1598
|
+
if (entry.has_screen === false || entry.hasScreen === false) score += 1;
|
|
1599
|
+
return score;
|
|
1600
|
+
};
|
|
1601
|
+
const namedScore = (entry) => {
|
|
1602
|
+
const name = String(entry.session_name ?? entry.sessionName ?? '');
|
|
1603
|
+
let score = name ? 1 : 0;
|
|
1604
|
+
if (/^terminal-[a-z0-9-]+$/i.test(name)) score += 2;
|
|
1605
|
+
if (entry.has_screen === true || entry.hasScreen === true) score += 1;
|
|
1606
|
+
return score;
|
|
1607
|
+
};
|
|
1608
|
+
const shouldReplace = (existing, candidate) => {
|
|
1609
|
+
const existingFallback = fallbackScore(existing);
|
|
1610
|
+
const candidateFallback = fallbackScore(candidate);
|
|
1611
|
+
if (existingFallback !== candidateFallback) return candidateFallback < existingFallback;
|
|
1612
|
+
const existingNamed = namedScore(existing);
|
|
1613
|
+
const candidateNamed = namedScore(candidate);
|
|
1614
|
+
if (existingNamed !== candidateNamed) return candidateNamed > existingNamed;
|
|
1615
|
+
return false;
|
|
1616
|
+
};
|
|
1617
|
+
const mergeGroups = (target, source) => {
|
|
1618
|
+
if (target === source) return target;
|
|
1619
|
+
for (const alias of source.aliases) {
|
|
1620
|
+
target.aliases.add(alias);
|
|
1621
|
+
groupsByAlias.set(alias, target);
|
|
1622
|
+
}
|
|
1623
|
+
source.merged = true;
|
|
1624
|
+
if (!target.value || (source.value && shouldReplace(target.value, source.value))) target.value = source.value;
|
|
1625
|
+
return target;
|
|
1626
|
+
};
|
|
1627
|
+
// Dedup by every stable alias we see. Docker/host fallback rows can share a
|
|
1628
|
+
// command id with the Cockpit-created session; QEMU fallback rows can instead
|
|
1629
|
+
// share only the formal session id while carrying a different command id. Keep
|
|
1630
|
+
// the named/screen-backed session row so the UI exposes the working attach URL.
|
|
1631
|
+
const groups = [];
|
|
1632
|
+
const groupsByAlias = new Map();
|
|
1313
1633
|
for (const s of sessions) {
|
|
1314
1634
|
const sessionId = s.id ?? s.session_id ?? s.sessionId;
|
|
1315
|
-
if (!sessionId
|
|
1316
|
-
|
|
1317
|
-
deduped.push({
|
|
1635
|
+
if (!sessionId) continue;
|
|
1636
|
+
const normalized = {
|
|
1318
1637
|
...s,
|
|
1319
1638
|
id: sessionId,
|
|
1320
1639
|
instance_id: s.instance_id ?? s.instanceId ?? instanceId,
|
|
1321
1640
|
agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
|
|
1322
1641
|
role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
|
|
1323
1642
|
attach_url: normalizeAttachUrl(s, sessionId),
|
|
1324
|
-
}
|
|
1643
|
+
};
|
|
1644
|
+
const aliases = sessionAliases(s, sessionId);
|
|
1645
|
+
let group = aliases.map((alias) => groupsByAlias.get(alias)).find(Boolean);
|
|
1646
|
+
if (!group) {
|
|
1647
|
+
group = { aliases: new Set(), value: null, merged: false };
|
|
1648
|
+
groups.push(group);
|
|
1649
|
+
}
|
|
1650
|
+
for (const alias of aliases) {
|
|
1651
|
+
const other = groupsByAlias.get(alias);
|
|
1652
|
+
if (other && other !== group) group = mergeGroups(group, other);
|
|
1653
|
+
group.aliases.add(alias);
|
|
1654
|
+
groupsByAlias.set(alias, group);
|
|
1655
|
+
}
|
|
1656
|
+
if (!group.value || shouldReplace(group.value, normalized)) group.value = normalized;
|
|
1325
1657
|
}
|
|
1326
|
-
return { instance_id: instanceId, sessions:
|
|
1658
|
+
return { instance_id: instanceId, sessions: groups.filter((group) => !group.merged && group.value).map((group) => group.value) };
|
|
1327
1659
|
}
|
|
1328
1660
|
|
|
1329
1661
|
async function endSession(executorUrl, instanceId, sessionId) {
|
|
@@ -1356,6 +1688,20 @@ async function endSession(executorUrl, instanceId, sessionId) {
|
|
|
1356
1688
|
};
|
|
1357
1689
|
}
|
|
1358
1690
|
|
|
1691
|
+
async function findReusableSession(executorUrl, instanceId, sessionName) {
|
|
1692
|
+
const sessions = (await getSessions(executorUrl, instanceId)).sessions;
|
|
1693
|
+
return sessions.find((s) => String(s.session_name ?? s.sessionName ?? '') === String(sessionName));
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function sessionResponseFromRow(row) {
|
|
1697
|
+
return {
|
|
1698
|
+
...row,
|
|
1699
|
+
id: row.id ?? row.session_id ?? row.sessionId,
|
|
1700
|
+
attach_url: row.attach_url ?? row.attachUrl,
|
|
1701
|
+
reused: true,
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1359
1705
|
export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = ALLOW_MOCK_EXECUTOR, token } = {}) {
|
|
1360
1706
|
const upstreamUrl = executorUrl;
|
|
1361
1707
|
const TOKEN = token ?? randomBytes(24).toString('hex');
|
|
@@ -1478,6 +1824,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1478
1824
|
const { status, body } = await endSession(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
|
|
1479
1825
|
return json(res, status, body);
|
|
1480
1826
|
}
|
|
1827
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/screen$/)) && req.method === 'GET') {
|
|
1828
|
+
const { status, body } = await getSessionScreen(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
|
|
1829
|
+
return json(res, status, body);
|
|
1830
|
+
}
|
|
1481
1831
|
// registry-bound, data-driven core — live, no app restart (#1592)
|
|
1482
1832
|
if (url.pathname === '/api/capabilities') {
|
|
1483
1833
|
const q = (url.searchParams.get('q') || '').trim();
|
|
@@ -1566,27 +1916,50 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1566
1916
|
if (backend) qs.set('backend', backend);
|
|
1567
1917
|
if (loadout) qs.set('loadout', loadout);
|
|
1568
1918
|
const sessionAgentId = await resolveSessionAgentId(upstreamUrl, id);
|
|
1919
|
+
const sessionName = sessionNameFor(id, { mode: mode || 'managed', backend: backend || 'tmux' });
|
|
1920
|
+
let sessionLaunch = defaultSessionLaunch();
|
|
1921
|
+
try {
|
|
1922
|
+
const inventory = await getInventory(upstreamUrl);
|
|
1923
|
+
sessionLaunch = defaultSessionLaunch(inventory.instances.find((inst) => inst.id === id));
|
|
1924
|
+
} catch {
|
|
1925
|
+
// Session creation can still proceed without an explicit cwd; the
|
|
1926
|
+
// executor/agent will fall back to its own process cwd.
|
|
1927
|
+
}
|
|
1928
|
+
// No pre-create reuse lookup: every create request gets its own uniquely
|
|
1929
|
+
// named session (multi-session per instance is supported). Recovery by
|
|
1930
|
+
// name below still catches a create that timed out after succeeding.
|
|
1931
|
+
const sessionBody = JSON.stringify({
|
|
1932
|
+
session_name: sessionName,
|
|
1933
|
+
session_backend: backend || 'tmux',
|
|
1934
|
+
session_class: mode || 'managed',
|
|
1935
|
+
command: sessionLaunch.command,
|
|
1936
|
+
args: sessionLaunch.args,
|
|
1937
|
+
...(sessionLaunch.working_dir ? { working_dir: sessionLaunch.working_dir } : {}),
|
|
1938
|
+
});
|
|
1569
1939
|
const candidates = unique([sessionAgentId, id]).flatMap((agentId) => [
|
|
1570
1940
|
{
|
|
1571
1941
|
target: `${upstreamUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
1572
1942
|
method: 'POST',
|
|
1573
1943
|
headers: { 'content-type': 'application/json' },
|
|
1574
|
-
body:
|
|
1575
|
-
session_backend: backend || 'tmux',
|
|
1576
|
-
session_class: mode || 'managed',
|
|
1577
|
-
command: 'bash',
|
|
1578
|
-
args: ['-l'],
|
|
1579
|
-
}),
|
|
1944
|
+
body: sessionBody,
|
|
1580
1945
|
},
|
|
1581
1946
|
{
|
|
1582
1947
|
target: `${upstreamUrl}/agents/${encodeURIComponent(agentId)}/sessions?${qs.toString()}`,
|
|
1583
1948
|
method: 'POST',
|
|
1949
|
+
headers: { 'content-type': 'application/json' },
|
|
1950
|
+
body: sessionBody,
|
|
1584
1951
|
},
|
|
1585
1952
|
]);
|
|
1586
1953
|
let sessionCreate;
|
|
1587
1954
|
try {
|
|
1588
|
-
sessionCreate = await fetchJsonFirst(candidates);
|
|
1955
|
+
sessionCreate = await fetchJsonFirst(candidates, { timeoutMs: 8000 });
|
|
1589
1956
|
} catch (err) {
|
|
1957
|
+
try {
|
|
1958
|
+
const reusable = await findReusableSession(upstreamUrl, id, sessionName);
|
|
1959
|
+
if (reusable) return json(res, 200, sessionResponseFromRow(reusable));
|
|
1960
|
+
} catch {
|
|
1961
|
+
// Preserve the original create failure; reuse is a recovery path only.
|
|
1962
|
+
}
|
|
1590
1963
|
return json(res, 409, {
|
|
1591
1964
|
error: 'agent_not_registered',
|
|
1592
1965
|
message: 'The instance is visible in inventory, but its agent has not registered yet; PTY sessions are not ready.',
|
|
@@ -1594,6 +1967,15 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1594
1967
|
});
|
|
1595
1968
|
}
|
|
1596
1969
|
const { status, body } = sessionCreate;
|
|
1970
|
+
if (status < 200 || status >= 300) {
|
|
1971
|
+
try {
|
|
1972
|
+
const reusable = await findReusableSession(upstreamUrl, id, sessionName);
|
|
1973
|
+
if (reusable) return json(res, 200, sessionResponseFromRow(reusable));
|
|
1974
|
+
} catch {
|
|
1975
|
+
// Return the upstream non-2xx response when no reusable session is visible.
|
|
1976
|
+
}
|
|
1977
|
+
return json(res, status, body);
|
|
1978
|
+
}
|
|
1597
1979
|
const wsBase = upstreamUrl.replace(/^http/i, 'ws');
|
|
1598
1980
|
const sessionId = body.id ?? body.session_id ?? body.sessionId;
|
|
1599
1981
|
if (status >= 200 && status < 300 && !sessionId && !body.attach_url && !body.attachUrl && !body.pty_ws_url && !body.ptyWsUrl) {
|
|
@@ -1609,8 +1991,8 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1609
1991
|
}
|
|
1610
1992
|
// Same as the list path (#1671): the attach segment must be the instance
|
|
1611
1993
|
// id the executor's pty-ws route accepts, not the resolved agent name.
|
|
1612
|
-
await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId });
|
|
1613
|
-
return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
|
|
1994
|
+
await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId, session_name: sessionName });
|
|
1995
|
+
return json(res, status, { ...body, id: sessionId, session_name: body.session_name ?? body.sessionName ?? sessionName, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
|
|
1614
1996
|
}
|
|
1615
1997
|
|
|
1616
1998
|
// --- management surface (UC-012): lifecycle + task cancel ---
|
|
@@ -1622,6 +2004,11 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1622
2004
|
await appendAudit('instance.lifecycle.requested', { instance_id: decodeURIComponent(m[1]), action: m[2], status: result.status, result: result.body });
|
|
1623
2005
|
return json(res, result.status, result.body);
|
|
1624
2006
|
}
|
|
2007
|
+
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/reconnect$/)) && req.method === 'POST') {
|
|
2008
|
+
const { status, body } = await reconnectInstance(upstreamUrl, decodeURIComponent(m[1]));
|
|
2009
|
+
await appendAudit('instance.reconnect.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
|
|
2010
|
+
return json(res, status, body);
|
|
2011
|
+
}
|
|
1625
2012
|
if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)$/)) && req.method === 'DELETE') {
|
|
1626
2013
|
const { status, body } = await destroyInstance(upstreamUrl, decodeURIComponent(m[1]));
|
|
1627
2014
|
await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
|
package/bridge/src/smoke.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { createExecutor } from '../../mock-executor/src/server.mjs';
|
|
6
|
-
import { createBridge } from './server.mjs';
|
|
6
|
+
import { createBridge, normalizeSessionRows } from './server.mjs';
|
|
7
7
|
|
|
8
8
|
const mock = createExecutor();
|
|
9
9
|
await new Promise((r) => mock.listen(0, '127.0.0.1', r));
|
|
@@ -51,11 +51,37 @@ try {
|
|
|
51
51
|
const demo = sess.sessions.find((s) => s.id === 'demo-shell');
|
|
52
52
|
assert.ok(demo, 'demo-shell session present');
|
|
53
53
|
assert.match(demo.attach_url, /^ws:\/\/.*\/agents\/.*\/sessions\/demo-shell\/attach$/, 'ws attach_url shape');
|
|
54
|
-
assert.ok(demo.
|
|
55
|
-
assert.equal(demo.
|
|
56
|
-
assert.equal(demo.
|
|
54
|
+
assert.ok(demo.liveness.replay_newest_seq >= 3, 'demo session has a seeded transcript');
|
|
55
|
+
assert.equal(demo.session_class, 'direct', 'demo session class');
|
|
56
|
+
assert.equal(demo.session_backend, 'native', 'demo session backend');
|
|
57
57
|
assert.equal(demo.role_policy, 'observe-default', 'session role policy');
|
|
58
58
|
|
|
59
|
+
const qemuDedup = normalizeSessionRows({
|
|
60
|
+
executorUrl,
|
|
61
|
+
instanceId: 'vm-1',
|
|
62
|
+
sessionAgentId: 'vm-agent-name',
|
|
63
|
+
sessions: [
|
|
64
|
+
{
|
|
65
|
+
id: 'sess-formal',
|
|
66
|
+
session_id: 'sess-formal',
|
|
67
|
+
command_id: 'cmd-real',
|
|
68
|
+
session_name: 'terminal-qemu',
|
|
69
|
+
command: '/bin/bash',
|
|
70
|
+
has_screen: true,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'sess-formal',
|
|
74
|
+
session_id: 'sess-formal',
|
|
75
|
+
command_id: 'sess-formal',
|
|
76
|
+
session_name: 'sess-formal',
|
|
77
|
+
command: '/bin/bash -l',
|
|
78
|
+
has_screen: false,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
assert.equal(qemuDedup.sessions.length, 1, 'QEMU formal session + fallback row dedupe to one session');
|
|
83
|
+
assert.equal(qemuDedup.sessions[0].session_name, 'terminal-qemu', 'dedupe keeps the named screen-backed session');
|
|
84
|
+
|
|
59
85
|
// missing instance param is a 400
|
|
60
86
|
assert.equal((await f("/api/sessions")).status, 400, 'sessions requires instance');
|
|
61
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwg/cockpit",
|
|
3
|
-
"version": "2026.7.
|
|
3
|
+
"version": "2026.7.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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"build:web": "npm --prefix web install --no-audit --no-fund && npm --prefix web run build",
|
|
44
44
|
"pack:dry": "npm pack --dry-run",
|
|
45
45
|
"publish:dry": "npm publish --dry-run --access public",
|
|
46
|
-
"smoke": "node mock-executor/src/smoke.mjs && node bridge/src/smoke.mjs && node shell-core/smoke.mjs",
|
|
46
|
+
"smoke": "node mock-executor/src/smoke.mjs && node bridge/src/smoke.mjs && node shell-core/smoke.mjs && node vscode/smoke.mjs",
|
|
47
47
|
"poc": "node poc/kill-bridge-isolation.mjs && node poc/security-checks.mjs",
|
|
48
48
|
"test:web": "npm --prefix web run typecheck && npm --prefix web run test",
|
|
49
49
|
"test": "npm run smoke && npm run poc",
|