@aiwg/cockpit 2026.7.9 → 2026.7.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/bridge/src/server.mjs +124 -17
- package/bridge/src/smoke.mjs +27 -1
- package/package.json +1 -1
- package/web/src/components/LaunchInstanceModal.test.tsx +1 -0
- package/web/src/components/LaunchInstanceModal.tsx +4 -1
- package/web/src/components/Sessions.test.tsx +8 -5
- package/web/src/components/Sessions.tsx +11 -5
- package/web/src/components/StartSessionModal.test.tsx +26 -3
- package/web/src/components/StartSessionModal.tsx +15 -3
- package/web/src/useSession.test.tsx +75 -1
- package/web/src/useSession.ts +33 -12
package/bridge/src/server.mjs
CHANGED
|
@@ -357,19 +357,23 @@ async function proxy(res, method, target) {
|
|
|
357
357
|
return json(res, r.status, body);
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption } = {}) {
|
|
360
|
+
async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption, timeoutMs = 0 } = {}) {
|
|
361
361
|
const failures = [];
|
|
362
362
|
for (const candidate of candidates) {
|
|
363
363
|
const target = typeof candidate === 'string' ? candidate : candidate.target;
|
|
364
364
|
const requestMethod = typeof candidate === 'string' ? method : candidate.method ?? method;
|
|
365
365
|
const requestHeaders = typeof candidate === 'string' ? headers : candidate.headers ?? headers;
|
|
366
366
|
const requestBody = typeof candidate === 'string' ? requestBodyOption : candidate.body ?? requestBodyOption;
|
|
367
|
+
const controller = timeoutMs > 0 ? new AbortController() : null;
|
|
368
|
+
const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
367
369
|
let r;
|
|
368
370
|
try {
|
|
369
|
-
r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody });
|
|
371
|
+
r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
|
|
370
372
|
} catch (err) {
|
|
371
373
|
failures.push(`${target} -> ${String(err?.message ?? err)}`);
|
|
372
374
|
continue;
|
|
375
|
+
} finally {
|
|
376
|
+
if (timeout) clearTimeout(timeout);
|
|
373
377
|
}
|
|
374
378
|
const responseBody = await r.json().catch(() => ({}));
|
|
375
379
|
if (r.ok) return { target, status: r.status, body: responseBody };
|
|
@@ -687,7 +691,18 @@ function normalizeTransport(posture) {
|
|
|
687
691
|
function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agentReady = false) {
|
|
688
692
|
const list = Array.isArray(backends) ? backends : [];
|
|
689
693
|
if (!list.length && runtimeKind === 'host') {
|
|
690
|
-
return [{
|
|
694
|
+
return [{
|
|
695
|
+
mode: 'managed',
|
|
696
|
+
backend: 'tmux',
|
|
697
|
+
observe: true,
|
|
698
|
+
drive: true,
|
|
699
|
+
replay: false,
|
|
700
|
+
keyframe: false,
|
|
701
|
+
available: agentReady,
|
|
702
|
+
reason: agentReady
|
|
703
|
+
? 'agentic-sandbox v1 host session API default'
|
|
704
|
+
: 'host runtime is provisioned but the host agent has not registered; PTY sessions are not ready',
|
|
705
|
+
}];
|
|
691
706
|
}
|
|
692
707
|
if (!list.length && ['docker', 'container', 'vm', 'qemu', 'kvm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
|
|
693
708
|
return [{
|
|
@@ -751,6 +766,28 @@ function normalizeInstance(executorUrl, i) {
|
|
|
751
766
|
};
|
|
752
767
|
}
|
|
753
768
|
|
|
769
|
+
function defaultSessionLaunch(instance) {
|
|
770
|
+
const runtime = String(instance?.runtime_posture?.kind ?? instance?.runtime ?? '').toLowerCase();
|
|
771
|
+
if (runtime === 'host') {
|
|
772
|
+
return {
|
|
773
|
+
command: 'bash',
|
|
774
|
+
args: ['-l'],
|
|
775
|
+
working_dir: instance?.launch_context?.cwd,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
|
|
779
|
+
return {
|
|
780
|
+
command: '/bin/bash',
|
|
781
|
+
args: ['-lc', 'cd "${HOME:-/root}" && exec /bin/bash -l'],
|
|
782
|
+
working_dir: '/root',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
command: 'bash',
|
|
787
|
+
args: ['-l'],
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
754
791
|
function runtimeExtensionFromCard(card) {
|
|
755
792
|
const extensions = card?.capabilities?.extensions;
|
|
756
793
|
if (!Array.isArray(extensions)) return null;
|
|
@@ -1284,6 +1321,10 @@ async function getSessions(executorUrl, instanceId) {
|
|
|
1284
1321
|
`${executorUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
1285
1322
|
]));
|
|
1286
1323
|
const sessions = asArrayFromEnvelope(body, ['sessions', 'items', 'data']);
|
|
1324
|
+
return normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId });
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
export function normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId = instanceId }) {
|
|
1287
1328
|
const wsBase = executorUrl.replace(/^http/i, 'ws');
|
|
1288
1329
|
const normalizeAttachUrl = (s, sessionId) => {
|
|
1289
1330
|
const explicit = s.attach_url ?? s.attachUrl;
|
|
@@ -1304,26 +1345,83 @@ async function getSessions(executorUrl, instanceId) {
|
|
|
1304
1345
|
// agent name is only needed for the session-list FETCH, not the attach path.
|
|
1305
1346
|
return `${wsBase}/agents/${encodeURIComponent(instanceId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
|
|
1306
1347
|
};
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1348
|
+
const sessionAliases = (entry, sessionId) => {
|
|
1349
|
+
const aliases = [`session:${sessionId}`];
|
|
1350
|
+
const commandId = entry.command_id ?? entry.commandId;
|
|
1351
|
+
if (commandId) aliases.push(`command:${commandId}`);
|
|
1352
|
+
return aliases;
|
|
1353
|
+
};
|
|
1354
|
+
const fallbackScore = (entry) => {
|
|
1355
|
+
const sessionId = String(entry.id ?? entry.session_id ?? entry.sessionId ?? '');
|
|
1356
|
+
const commandId = String(entry.command_id ?? entry.commandId ?? '');
|
|
1357
|
+
const id = String(entry.id ?? '');
|
|
1358
|
+
const name = String(entry.session_name ?? entry.sessionName ?? '');
|
|
1359
|
+
const command = String(entry.command ?? '').trim();
|
|
1360
|
+
let score = 0;
|
|
1361
|
+
if (id && commandId && id === commandId) score += 2;
|
|
1362
|
+
if (name && (name === sessionId || name === commandId)) score += 2;
|
|
1363
|
+
if (/^\/?bin\/bash\s+-l$/.test(command)) score += 1;
|
|
1364
|
+
if (entry.has_screen === false || entry.hasScreen === false) score += 1;
|
|
1365
|
+
return score;
|
|
1366
|
+
};
|
|
1367
|
+
const namedScore = (entry) => {
|
|
1368
|
+
const name = String(entry.session_name ?? entry.sessionName ?? '');
|
|
1369
|
+
let score = name ? 1 : 0;
|
|
1370
|
+
if (/^terminal-[a-z0-9-]+$/i.test(name)) score += 2;
|
|
1371
|
+
if (entry.has_screen === true || entry.hasScreen === true) score += 1;
|
|
1372
|
+
return score;
|
|
1373
|
+
};
|
|
1374
|
+
const shouldReplace = (existing, candidate) => {
|
|
1375
|
+
const existingFallback = fallbackScore(existing);
|
|
1376
|
+
const candidateFallback = fallbackScore(candidate);
|
|
1377
|
+
if (existingFallback !== candidateFallback) return candidateFallback < existingFallback;
|
|
1378
|
+
const existingNamed = namedScore(existing);
|
|
1379
|
+
const candidateNamed = namedScore(candidate);
|
|
1380
|
+
if (existingNamed !== candidateNamed) return candidateNamed > existingNamed;
|
|
1381
|
+
return false;
|
|
1382
|
+
};
|
|
1383
|
+
const mergeGroups = (target, source) => {
|
|
1384
|
+
if (target === source) return target;
|
|
1385
|
+
for (const alias of source.aliases) {
|
|
1386
|
+
target.aliases.add(alias);
|
|
1387
|
+
groupsByAlias.set(alias, target);
|
|
1388
|
+
}
|
|
1389
|
+
source.merged = true;
|
|
1390
|
+
if (!target.value || (source.value && shouldReplace(target.value, source.value))) target.value = source.value;
|
|
1391
|
+
return target;
|
|
1392
|
+
};
|
|
1393
|
+
// Dedup by every stable alias we see. Docker/host fallback rows can share a
|
|
1394
|
+
// command id with the Cockpit-created session; QEMU fallback rows can instead
|
|
1395
|
+
// share only the formal session id while carrying a different command id. Keep
|
|
1396
|
+
// the named/screen-backed session row so the UI exposes the working attach URL.
|
|
1397
|
+
const groups = [];
|
|
1398
|
+
const groupsByAlias = new Map();
|
|
1313
1399
|
for (const s of sessions) {
|
|
1314
1400
|
const sessionId = s.id ?? s.session_id ?? s.sessionId;
|
|
1315
|
-
if (!sessionId
|
|
1316
|
-
|
|
1317
|
-
deduped.push({
|
|
1401
|
+
if (!sessionId) continue;
|
|
1402
|
+
const normalized = {
|
|
1318
1403
|
...s,
|
|
1319
1404
|
id: sessionId,
|
|
1320
1405
|
instance_id: s.instance_id ?? s.instanceId ?? instanceId,
|
|
1321
1406
|
agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
|
|
1322
1407
|
role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
|
|
1323
1408
|
attach_url: normalizeAttachUrl(s, sessionId),
|
|
1324
|
-
}
|
|
1409
|
+
};
|
|
1410
|
+
const aliases = sessionAliases(s, sessionId);
|
|
1411
|
+
let group = aliases.map((alias) => groupsByAlias.get(alias)).find(Boolean);
|
|
1412
|
+
if (!group) {
|
|
1413
|
+
group = { aliases: new Set(), value: null, merged: false };
|
|
1414
|
+
groups.push(group);
|
|
1415
|
+
}
|
|
1416
|
+
for (const alias of aliases) {
|
|
1417
|
+
const other = groupsByAlias.get(alias);
|
|
1418
|
+
if (other && other !== group) group = mergeGroups(group, other);
|
|
1419
|
+
group.aliases.add(alias);
|
|
1420
|
+
groupsByAlias.set(alias, group);
|
|
1421
|
+
}
|
|
1422
|
+
if (!group.value || shouldReplace(group.value, normalized)) group.value = normalized;
|
|
1325
1423
|
}
|
|
1326
|
-
return { instance_id: instanceId, sessions:
|
|
1424
|
+
return { instance_id: instanceId, sessions: groups.filter((group) => !group.merged && group.value).map((group) => group.value) };
|
|
1327
1425
|
}
|
|
1328
1426
|
|
|
1329
1427
|
async function endSession(executorUrl, instanceId, sessionId) {
|
|
@@ -1566,6 +1664,14 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1566
1664
|
if (backend) qs.set('backend', backend);
|
|
1567
1665
|
if (loadout) qs.set('loadout', loadout);
|
|
1568
1666
|
const sessionAgentId = await resolveSessionAgentId(upstreamUrl, id);
|
|
1667
|
+
let sessionLaunch = defaultSessionLaunch();
|
|
1668
|
+
try {
|
|
1669
|
+
const inventory = await getInventory(upstreamUrl);
|
|
1670
|
+
sessionLaunch = defaultSessionLaunch(inventory.instances.find((inst) => inst.id === id));
|
|
1671
|
+
} catch {
|
|
1672
|
+
// Session creation can still proceed without an explicit cwd; the
|
|
1673
|
+
// executor/agent will fall back to its own process cwd.
|
|
1674
|
+
}
|
|
1569
1675
|
const candidates = unique([sessionAgentId, id]).flatMap((agentId) => [
|
|
1570
1676
|
{
|
|
1571
1677
|
target: `${upstreamUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
|
|
@@ -1574,8 +1680,9 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1574
1680
|
body: JSON.stringify({
|
|
1575
1681
|
session_backend: backend || 'tmux',
|
|
1576
1682
|
session_class: mode || 'managed',
|
|
1577
|
-
command:
|
|
1578
|
-
args:
|
|
1683
|
+
command: sessionLaunch.command,
|
|
1684
|
+
args: sessionLaunch.args,
|
|
1685
|
+
...(sessionLaunch.working_dir ? { working_dir: sessionLaunch.working_dir } : {}),
|
|
1579
1686
|
}),
|
|
1580
1687
|
},
|
|
1581
1688
|
{
|
|
@@ -1585,7 +1692,7 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1585
1692
|
]);
|
|
1586
1693
|
let sessionCreate;
|
|
1587
1694
|
try {
|
|
1588
|
-
sessionCreate = await fetchJsonFirst(candidates);
|
|
1695
|
+
sessionCreate = await fetchJsonFirst(candidates, { timeoutMs: 8000 });
|
|
1589
1696
|
} catch (err) {
|
|
1590
1697
|
return json(res, 409, {
|
|
1591
1698
|
error: 'agent_not_registered',
|
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));
|
|
@@ -56,6 +56,32 @@ try {
|
|
|
56
56
|
assert.equal(demo.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.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",
|
|
@@ -139,6 +139,7 @@ describe('LaunchInstanceModal', () => {
|
|
|
139
139
|
.find((call) => String(call[0]).includes('/api/instances') && call[1]?.method === 'POST');
|
|
140
140
|
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
|
|
141
141
|
runtime: 'qemu',
|
|
142
|
+
agentshare: true,
|
|
142
143
|
ssh_key: '~/.ssh/agentic_ed25519.pub',
|
|
143
144
|
});
|
|
144
145
|
});
|
|
@@ -141,7 +141,10 @@ export function LaunchInstanceModal({
|
|
|
141
141
|
body.image = image === '__custom__' ? customImage.trim() : image;
|
|
142
142
|
body.agentshare = true;
|
|
143
143
|
}
|
|
144
|
-
if (runtime === 'qemu'
|
|
144
|
+
if (runtime === 'qemu') {
|
|
145
|
+
body.agentshare = true;
|
|
146
|
+
if (sshKey.trim()) body.ssh_key = sshKey.trim();
|
|
147
|
+
}
|
|
145
148
|
if (runtime === 'docker' && mounts.trim()) body.mounts = mounts.split('\n').map((m) => m.trim()).filter(Boolean);
|
|
146
149
|
const op = await api<{ id?: string; instance_id?: string; instanceId?: string; operation?: { id?: string }; result?: { instance_id?: string; instanceId?: string } }>('/api/instances', {
|
|
147
150
|
method: 'POST',
|
|
@@ -121,7 +121,7 @@ describe('Sessions', () => {
|
|
|
121
121
|
expect(await within(nav).findByTitle('sess-new')).toBeTruthy();
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
it('
|
|
124
|
+
it('keeps controller posture when a different session is selected while driving (#1670)', async () => {
|
|
125
125
|
const session = stubSession(); // currently attached to .../sessions/sess-1/attach as controller
|
|
126
126
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
127
127
|
const url = String(input);
|
|
@@ -137,11 +137,12 @@ describe('Sessions', () => {
|
|
|
137
137
|
const nav = screen.getByLabelText('Instances and sessions');
|
|
138
138
|
const sessBtn = await within(nav).findByTitle('sess-2');
|
|
139
139
|
fireEvent.click(sessBtn);
|
|
140
|
-
// Selecting a not-yet-attached session
|
|
141
|
-
|
|
140
|
+
// Selecting a not-yet-attached session should not silently downgrade an
|
|
141
|
+
// operator who is already driving another session.
|
|
142
|
+
expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'controller');
|
|
142
143
|
});
|
|
143
144
|
|
|
144
|
-
it('
|
|
145
|
+
it('reattaches with replay instead of downgrading when re-selecting the session already attached', async () => {
|
|
145
146
|
const session = stubSession(); // state.url === .../sessions/sess-1/attach, role controller
|
|
146
147
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
147
148
|
const url = String(input);
|
|
@@ -156,8 +157,10 @@ describe('Sessions', () => {
|
|
|
156
157
|
|
|
157
158
|
const nav = screen.getByLabelText('Instances and sessions');
|
|
158
159
|
fireEvent.click(await within(nav).findByTitle('sess-1'));
|
|
159
|
-
// Clicking the session we already drive
|
|
160
|
+
// Clicking the session we already drive replays/reasserts controller; it
|
|
161
|
+
// must not downgrade us back to observer.
|
|
160
162
|
expect(session.attach).not.toHaveBeenCalled();
|
|
163
|
+
expect(session.replay).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-1/attach', 'controller');
|
|
161
164
|
});
|
|
162
165
|
|
|
163
166
|
it('distinguishes sessions by name + backend + viewer count in the nav (#1670)', async () => {
|
|
@@ -53,6 +53,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
53
53
|
.then((d) => {
|
|
54
54
|
const nextSessions = d.sessions ?? [];
|
|
55
55
|
setSessions(nextSessions);
|
|
56
|
+
setSessionErr('');
|
|
56
57
|
setAttachUrl((currentUrl) => {
|
|
57
58
|
if (currentUrl && nextSessions.some((s) => s.attach_url === currentUrl)) return currentUrl;
|
|
58
59
|
return nextSessions[0]?.attach_url ?? '';
|
|
@@ -147,13 +148,18 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
147
148
|
<li key={s.id}>
|
|
148
149
|
<button
|
|
149
150
|
className={`nav-session${selS ? ' selected' : ''}${live ? ' live' : ''}`}
|
|
150
|
-
// Selecting a session
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
151
|
+
// Selecting a session keeps the operator's current posture when
|
|
152
|
+
// possible. If they are already driving, do not silently downgrade
|
|
153
|
+
// to observe; if they click the attached row, reattach+replay so
|
|
154
|
+
// Docker/tmux streams repaint and control is reasserted.
|
|
154
155
|
onClick={() => {
|
|
156
|
+
const role = session.state.role === 'controller' && selectedBackend?.drive !== false ? 'controller' : 'observer';
|
|
155
157
|
setAttachUrl(s.attach_url);
|
|
156
|
-
if (s.attach_url
|
|
158
|
+
if (s.attach_url === session.state.url && attached) {
|
|
159
|
+
session.replay(s.attach_url, role);
|
|
160
|
+
} else {
|
|
161
|
+
session.attach(s.attach_url, false, role);
|
|
162
|
+
}
|
|
157
163
|
}}
|
|
158
164
|
title={s.id}
|
|
159
165
|
>
|
|
@@ -11,13 +11,13 @@ const INSTANCE = {
|
|
|
11
11
|
launch_context: { loadout: 'agentic-dev' },
|
|
12
12
|
session_backends: [{ mode: 'managed', backend: 'tmux', available: true, drive: true }],
|
|
13
13
|
};
|
|
14
|
-
function mockFetch(postImpl?: () => Response | Promise<Response>) {
|
|
14
|
+
function mockFetch(postImpl?: (init?: RequestInit) => Response | Promise<Response>) {
|
|
15
15
|
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
16
16
|
const url = String(input);
|
|
17
17
|
const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
18
18
|
if (url.includes('/api/inventory')) return ok({ instances: [INSTANCE] });
|
|
19
19
|
if (url.includes('/sessions') && init?.method === 'POST') {
|
|
20
|
-
return postImpl ? postImpl() : ok({ id: 'sess-x', attach_url: 'ws://x/agents/i/sessions/sess-x/attach' });
|
|
20
|
+
return postImpl ? postImpl(init) : ok({ id: 'sess-x', attach_url: 'ws://x/agents/i/sessions/sess-x/attach' });
|
|
21
21
|
}
|
|
22
22
|
return new Response('{}', { status: 404 });
|
|
23
23
|
}) as unknown as typeof fetch;
|
|
@@ -32,7 +32,7 @@ function stubSession(attached = false): SessionApi {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
beforeEach(() => { (window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 't'; });
|
|
35
|
-
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
|
35
|
+
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); });
|
|
36
36
|
|
|
37
37
|
describe('StartSessionModal (#1640/#1641)', () => {
|
|
38
38
|
it('renders nothing when closed', () => {
|
|
@@ -83,4 +83,27 @@ describe('StartSessionModal (#1640/#1641)', () => {
|
|
|
83
83
|
await waitFor(() => expect(screen.getByText(/→ 500/)).toBeTruthy());
|
|
84
84
|
expect(alertSpy).not.toHaveBeenCalled();
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
it('unlocks when session start times out', async () => {
|
|
88
|
+
globalThis.fetch = mockFetch((init) => new Promise<Response>((_, reject) => {
|
|
89
|
+
init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
|
90
|
+
}));
|
|
91
|
+
render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} startTimeoutMs={5} />);
|
|
92
|
+
const startBtn = await screen.findByRole('button', { name: /start session/i });
|
|
93
|
+
await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
|
|
94
|
+
fireEvent.click(startBtn);
|
|
95
|
+
expect(await screen.findByRole('button', { name: /starting/i })).toBeTruthy();
|
|
96
|
+
await waitFor(() => expect(screen.getByText(/timed out/i)).toBeTruthy());
|
|
97
|
+
expect(screen.getByRole('button', { name: /start session/i })).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('unlocks when the bridge returns no attach URL', async () => {
|
|
101
|
+
globalThis.fetch = mockFetch(() => new Response('{"id":"sess-x"}', { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
102
|
+
render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
|
|
103
|
+
const startBtn = await screen.findByRole('button', { name: /start session/i });
|
|
104
|
+
await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
|
|
105
|
+
fireEvent.click(startBtn);
|
|
106
|
+
await waitFor(() => expect(screen.getByText(/no attach URL/i)).toBeTruthy());
|
|
107
|
+
expect(screen.getByRole('button', { name: /start session/i })).toBeTruthy();
|
|
108
|
+
});
|
|
86
109
|
});
|
|
@@ -15,9 +15,12 @@ interface Props {
|
|
|
15
15
|
session: SessionApi;
|
|
16
16
|
onStarted: () => void; // switch to the Sessions workspace after attach
|
|
17
17
|
initialInstanceId?: string; // pre-select when opened from a specific instance/board
|
|
18
|
+
startTimeoutMs?: number;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
const START_SESSION_TIMEOUT_MS = 15000;
|
|
22
|
+
|
|
23
|
+
export function StartSessionModal({ open, onClose, session, onStarted, initialInstanceId, startTimeoutMs = START_SESSION_TIMEOUT_MS }: Props) {
|
|
21
24
|
const [instances, setInstances] = useState<Instance[]>([]);
|
|
22
25
|
const [instId, setInstId] = useState('');
|
|
23
26
|
const [loadoutId, setLoadoutId] = useState('');
|
|
@@ -77,17 +80,26 @@ export function StartSessionModal({ open, onClose, session, onStarted, initialIn
|
|
|
77
80
|
const start = async () => {
|
|
78
81
|
if (!current || !selectedBackend) return;
|
|
79
82
|
setBusy(true); setErr('');
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const timeout = window.setTimeout(() => controller.abort(), startTimeoutMs);
|
|
80
85
|
try {
|
|
81
86
|
const qs = new URLSearchParams({ mode: selectedBackend.mode, backend: selectedBackend.backend });
|
|
82
87
|
const s = await api<{ id: string; attach_url: string }>(
|
|
83
|
-
`/api/instances/${encodeURIComponent(current.id)}/sessions?${qs}`,
|
|
88
|
+
`/api/instances/${encodeURIComponent(current.id)}/sessions?${qs}`,
|
|
89
|
+
{ method: 'POST', signal: controller.signal },
|
|
84
90
|
);
|
|
91
|
+
if (!s.attach_url) throw new Error('Session started but no attach URL was returned.');
|
|
85
92
|
session.attach(s.attach_url, false, posture);
|
|
86
93
|
onStarted();
|
|
87
94
|
onClose();
|
|
88
95
|
} catch (e) {
|
|
89
|
-
|
|
96
|
+
const message = e instanceof DOMException && e.name === 'AbortError'
|
|
97
|
+
? 'Starting the session timed out. The instance may still be preparing PTY support; refresh sessions and try again.'
|
|
98
|
+
: (e as Error).message;
|
|
99
|
+
setErr(message); // inline — the operator sees exactly why
|
|
90
100
|
setBusy(false);
|
|
101
|
+
} finally {
|
|
102
|
+
window.clearTimeout(timeout);
|
|
91
103
|
}
|
|
92
104
|
};
|
|
93
105
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { renderHook, act } from '@testing-library/react';
|
|
3
|
-
import { useSession } from './useSession';
|
|
3
|
+
import { stripTerminalAutoResponses, useSession } from './useSession';
|
|
4
4
|
|
|
5
5
|
// Minimal WebSocket double: records every constructed socket and lets the test
|
|
6
6
|
// drive open/close/message. Mirrors the readiness-race timing (#1669).
|
|
@@ -23,6 +23,17 @@ beforeEach(() => {
|
|
|
23
23
|
});
|
|
24
24
|
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
|
|
25
25
|
|
|
26
|
+
describe('stripTerminalAutoResponses', () => {
|
|
27
|
+
it('drops OSC color query replies without dropping real input', () => {
|
|
28
|
+
const data = '\x1b]10;rgb:cdcd/d3d3/dede\x07ls\x1b]11;rgb:0a0a/0c0c/1010\x1b\\\r';
|
|
29
|
+
expect(stripTerminalAutoResponses(data)).toBe('ls\r');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('drops terminal identity/status replies', () => {
|
|
33
|
+
expect(stripTerminalAutoResponses('\x1b[?1;2chello\x1b[0n\x1b[12;40R')).toBe('hello');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
26
37
|
describe('useSession — retry through the PTY-readiness window (#1669)', () => {
|
|
27
38
|
it('reconnects on an early empty close instead of giving up', () => {
|
|
28
39
|
const { result } = renderHook(() => useSession());
|
|
@@ -77,4 +88,67 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
|
|
|
77
88
|
// 1 initial + 6 retries = 7 sockets, then it stops.
|
|
78
89
|
expect(MockWS.instances.length).toBeLessThanOrEqual(7);
|
|
79
90
|
});
|
|
91
|
+
|
|
92
|
+
it('ignores stale socket messages after switching sessions', () => {
|
|
93
|
+
const { result } = renderHook(() => useSession());
|
|
94
|
+
act(() => { result.current.attach('ws://x/agents/i/sessions/old/attach', false, 'controller'); });
|
|
95
|
+
const old = MockWS.instances[0];
|
|
96
|
+
act(() => { result.current.attach('ws://x/agents/i/sessions/new/attach', false, 'observer'); });
|
|
97
|
+
const current = MockWS.instances[1];
|
|
98
|
+
|
|
99
|
+
act(() => {
|
|
100
|
+
old.emit('open');
|
|
101
|
+
old.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
102
|
+
old.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
103
|
+
old.emit('close');
|
|
104
|
+
});
|
|
105
|
+
expect(old.sent).toEqual([]);
|
|
106
|
+
expect(result.current.state.url).toBe('ws://x/agents/i/sessions/new/attach');
|
|
107
|
+
expect(result.current.state.role).toBeNull();
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
current.emit('open');
|
|
111
|
+
current.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
112
|
+
current.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'observer' } }) });
|
|
113
|
+
});
|
|
114
|
+
expect(JSON.parse(current.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer' } });
|
|
115
|
+
expect(result.current.state.role).toBe('observer');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does not let a stale close clear the active controller role', () => {
|
|
119
|
+
const { result } = renderHook(() => useSession());
|
|
120
|
+
act(() => { result.current.attach('ws://x/old', false, 'observer'); });
|
|
121
|
+
const old = MockWS.instances[0];
|
|
122
|
+
act(() => { result.current.attach('ws://x/new', false, 'controller'); });
|
|
123
|
+
const current = MockWS.instances[1];
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
current.emit('open');
|
|
127
|
+
current.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
128
|
+
});
|
|
129
|
+
expect(result.current.state.role).toBe('controller');
|
|
130
|
+
|
|
131
|
+
act(() => { old.emit('close'); });
|
|
132
|
+
expect(result.current.state.url).toBe('ws://x/new');
|
|
133
|
+
expect(result.current.state.attached).toBe(true);
|
|
134
|
+
expect(result.current.state.role).toBe('controller');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('reattaches for replay immediately and asks the active socket for replay_from', () => {
|
|
138
|
+
const { result } = renderHook(() => useSession());
|
|
139
|
+
act(() => { result.current.attach('ws://x/session', false, 'controller'); });
|
|
140
|
+
const first = MockWS.instances[0];
|
|
141
|
+
act(() => {
|
|
142
|
+
first.emit('open');
|
|
143
|
+
first.emit('message', { data: JSON.stringify({ op: 'output', seq: 12, payload: { data: btoa('ready') } }) });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
act(() => { result.current.replay('ws://x/session', 'controller'); });
|
|
147
|
+
expect(MockWS.instances).toHaveLength(2);
|
|
148
|
+
const replay = MockWS.instances[1];
|
|
149
|
+
expect(replay.url).toBe('ws://x/session?replay_from=12');
|
|
150
|
+
|
|
151
|
+
act(() => { replay.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
|
|
152
|
+
expect(JSON.parse(replay.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'controller', replay_from: 12 } });
|
|
153
|
+
});
|
|
80
154
|
});
|
package/web/src/useSession.ts
CHANGED
|
@@ -51,6 +51,13 @@ const toB64 = (s: string): string => {
|
|
|
51
51
|
return btoa(bin);
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
export function stripTerminalAutoResponses(data: string): string {
|
|
55
|
+
return data
|
|
56
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
|
57
|
+
.replace(/\x1b\[\?[0-9;]*[cnhl]/g, '')
|
|
58
|
+
.replace(/\x1b\[[0-9;]*[Rn]/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
function stripAnsi(text: string): string {
|
|
55
62
|
return text
|
|
56
63
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
@@ -82,6 +89,7 @@ export function useSession() {
|
|
|
82
89
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
83
90
|
const roleRef = useRef<Role>(null); // current role, read by term.onData without re-subscribing
|
|
84
91
|
const outputTailRef = useRef('');
|
|
92
|
+
const connectionIdRef = useRef(0);
|
|
85
93
|
// Retry-through-readiness state (#1669): a freshly-launched VM/container can
|
|
86
94
|
// accept the pty-ws attach, send 0 frames, and close within ~2s because the
|
|
87
95
|
// agent's PTY/tmux isn't streamable yet. Rather than show a hard
|
|
@@ -111,7 +119,9 @@ export function useSession() {
|
|
|
111
119
|
noteOutput(bytes);
|
|
112
120
|
try { termRef.current?.write(bytes); } catch { /* term not open */ }
|
|
113
121
|
};
|
|
114
|
-
const
|
|
122
|
+
const encodeOp = (op: string, payload?: unknown) => JSON.stringify(payload === undefined ? { op } : { op, payload });
|
|
123
|
+
const sendOp = (op: string, payload?: unknown) => { try { wsRef.current?.send(encodeOp(op, payload)); } catch { /* socket closed */ } };
|
|
124
|
+
const sendOn = (ws: WebSocket, op: string, payload?: unknown) => { try { ws.send(encodeOp(op, payload)); } catch { /* socket closed */ } };
|
|
115
125
|
const clearResponseNeeded = () => setResponseNeeded({ needed: false, prompt: '', since: null, source: 'pty' });
|
|
116
126
|
|
|
117
127
|
// Mount the terminal into the host element (ref callback from the Sessions tab).
|
|
@@ -139,11 +149,14 @@ export function useSession() {
|
|
|
139
149
|
// Forward keystrokes to the PTY only while driving.
|
|
140
150
|
term.onData((data) => {
|
|
141
151
|
if (roleRef.current !== 'controller') return;
|
|
152
|
+
const userData = stripTerminalAutoResponses(data);
|
|
153
|
+
if (!userData) return;
|
|
142
154
|
clearResponseNeeded();
|
|
143
|
-
sendOp('pty.session_input', { data: toB64(
|
|
155
|
+
sendOp('pty.session_input', { data: toB64(userData) });
|
|
144
156
|
});
|
|
145
157
|
// Keep tmux sized to the terminal so redraws don't wrap/overflow.
|
|
146
158
|
term.onResize(({ cols, rows }) => {
|
|
159
|
+
if (roleRef.current !== 'controller') return;
|
|
147
160
|
if (cols < RESIZE_FLOOR_COLS || rows < RESIZE_FLOOR_ROWS) return;
|
|
148
161
|
sendOp('pty.session_resize', { cols, rows });
|
|
149
162
|
});
|
|
@@ -172,11 +185,14 @@ export function useSession() {
|
|
|
172
185
|
try { termRef.current?.dispose(); } catch { /* */ }
|
|
173
186
|
closedByUserRef.current = true;
|
|
174
187
|
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
|
188
|
+
connectionIdRef.current += 1;
|
|
175
189
|
wsRef.current?.close();
|
|
176
190
|
}, []);
|
|
177
191
|
|
|
178
192
|
const attach = useCallback((url: string, replay = false, requestedRole: Exclude<Role, null> = 'observer') => {
|
|
179
193
|
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
|
194
|
+
const connectionId = connectionIdRef.current + 1;
|
|
195
|
+
connectionIdRef.current = connectionId;
|
|
180
196
|
closedByUserRef.current = false;
|
|
181
197
|
retryRef.current = 0;
|
|
182
198
|
gotFrameRef.current = false;
|
|
@@ -191,11 +207,16 @@ export function useSession() {
|
|
|
191
207
|
|
|
192
208
|
// Open (or re-open, on a readiness retry) the data-plane socket.
|
|
193
209
|
const connect = () => {
|
|
210
|
+
if (connectionIdRef.current !== connectionId || closedByUserRef.current) return;
|
|
194
211
|
const ws = new WebSocket(replay ? `${url}?replay_from=${lastSeq.current}` : url);
|
|
195
212
|
wsRef.current = ws;
|
|
196
213
|
let gone = false; // a failing socket fires BOTH 'error' and 'close' — handle once
|
|
197
|
-
ws.addEventListener('open', () =>
|
|
214
|
+
ws.addEventListener('open', () => {
|
|
215
|
+
if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
|
|
216
|
+
setState((s) => ({ ...s, attached: true, url }));
|
|
217
|
+
});
|
|
198
218
|
const onGone = (kind: 'close' | 'error') => {
|
|
219
|
+
if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
|
|
199
220
|
if (gone) return;
|
|
200
221
|
gone = true;
|
|
201
222
|
roleRef.current = null;
|
|
@@ -216,14 +237,15 @@ export function useSession() {
|
|
|
216
237
|
ws.addEventListener('close', () => onGone('close'));
|
|
217
238
|
ws.addEventListener('error', () => onGone('error'));
|
|
218
239
|
ws.addEventListener('message', (ev) => {
|
|
240
|
+
if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
|
|
219
241
|
let m: WsMsg;
|
|
220
242
|
try { m = JSON.parse(ev.data as string); } catch { return; }
|
|
221
243
|
switch (m.op) {
|
|
222
244
|
case 'binding_hello': {
|
|
223
|
-
|
|
245
|
+
sendOn(ws, 'pty.join_session', { role: requestedRole, ...(replay ? { replay_from: lastSeq.current } : {}) });
|
|
224
246
|
// Tell the PTY our current dimensions up front so the first tmux redraw fits.
|
|
225
247
|
const t = termRef.current;
|
|
226
|
-
if (t && t.cols >= RESIZE_FLOOR_COLS && t.rows >= RESIZE_FLOOR_ROWS)
|
|
248
|
+
if (requestedRole === 'controller' && t && t.cols >= RESIZE_FLOOR_COLS && t.rows >= RESIZE_FLOOR_ROWS) sendOn(ws, 'pty.session_resize', { cols: t.cols, rows: t.rows });
|
|
227
249
|
break;
|
|
228
250
|
}
|
|
229
251
|
case 'role_assigned': {
|
|
@@ -232,10 +254,9 @@ export function useSession() {
|
|
|
232
254
|
setState((s) => ({ ...s, role }));
|
|
233
255
|
// Only a controller may type into the terminal; observers are read-only.
|
|
234
256
|
if (termRef.current) termRef.current.options.disableStdin = role !== 'controller';
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
if (!gotFrameRef.current) sendOp('pty.request_keyframe');
|
|
257
|
+
// The gateway owns replay/keyframe delivery for joined sessions.
|
|
258
|
+
// Avoid probing here: on some backends keyframe requests are
|
|
259
|
+
// controller-gated and create noisy permission errors for observers.
|
|
239
260
|
requestAnimationFrame(() => fit());
|
|
240
261
|
break;
|
|
241
262
|
}
|
|
@@ -259,6 +280,7 @@ export function useSession() {
|
|
|
259
280
|
|
|
260
281
|
const detach = useCallback(() => {
|
|
261
282
|
closedByUserRef.current = true;
|
|
283
|
+
connectionIdRef.current += 1;
|
|
262
284
|
roleRef.current = null;
|
|
263
285
|
if (termRef.current) termRef.current.options.disableStdin = true; // detached → read-only
|
|
264
286
|
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
|
@@ -267,9 +289,8 @@ export function useSession() {
|
|
|
267
289
|
}, []);
|
|
268
290
|
const replay = useCallback((url: string, requestedRole?: Exclude<Role, null>) => {
|
|
269
291
|
const role = requestedRole ?? roleRef.current ?? 'observer';
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}, [attach, detach]);
|
|
292
|
+
attach(url, true, role);
|
|
293
|
+
}, [attach]);
|
|
273
294
|
const requestKeyframe = useCallback(() => sendOp('pty.request_keyframe'), []);
|
|
274
295
|
// Composer line-input (the input row + Actions inject). Raw keystrokes go via term.onData.
|
|
275
296
|
const sendInput = useCallback((text: string): boolean => {
|