@logicseek/hello-cc 1.0.0 → 1.0.2-dsh.3
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/CHANGELOG.md +149 -0
- package/README.md +36 -0
- package/README.zh-CN.md +35 -0
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +9 -0
- package/docs/commands.md +53 -0
- package/docs/commands.zh-CN.md +48 -0
- package/docs/dsh.md +137 -0
- package/docs/dsh.zh-CN.md +139 -0
- package/docs/guide.md +58 -0
- package/docs/guide.zh-CN.md +55 -0
- package/docs/mcp.md +9 -0
- package/docs/mcp.zh-CN.md +23 -0
- package/docs/native.md +295 -0
- package/docs/native.zh-CN.md +253 -0
- package/docs/web-handoff.zh-CN.md +53 -0
- package/lib/cli/app.mjs +27 -6
- package/lib/cli/commands/coordination.mjs +3 -1
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/gc.mjs +182 -32
- package/lib/cli/commands/hook.mjs +241 -90
- package/lib/cli/commands/lock.mjs +34 -19
- package/lib/cli/commands/mcp.mjs +28 -0
- package/lib/cli/commands/misc.mjs +15 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/peer.mjs +21 -5
- package/lib/cli/commands/run.mjs +66 -21
- package/lib/cli/commands/task.mjs +6 -3
- package/lib/cli-args.mjs +4 -0
- package/lib/coordination-state.mjs +15 -1
- package/lib/core/coordination/event-retention.mjs +14 -2
- package/lib/core/coordination/gc-plan.mjs +3 -13
- package/lib/core/coordination/locks.mjs +45 -0
- package/lib/core/coordination/results.mjs +79 -0
- package/lib/core/peers/bindings.mjs +9 -3
- package/lib/diagnostics/codex.mjs +121 -0
- package/lib/integrations/codex-interactions.mjs +114 -0
- package/lib/integrations/dsh-collaboration.mjs +170 -0
- package/lib/integrations/dsh-cordis.d.ts +19 -0
- package/lib/integrations/dsh-cordis.mjs +177 -0
- package/lib/integrations/dsh.bundle.yml +7 -0
- package/lib/integrations/dsh.mjs +287 -0
- package/lib/integrations/native/claude.mjs +451 -0
- package/lib/integrations/native/codex.mjs +242 -0
- package/lib/integrations/native/dsh-acp.mjs +343 -0
- package/lib/integrations/native/index.mjs +19 -0
- package/lib/integrations/native/interactions.mjs +53 -0
- package/lib/integrations/native/jsonrpc.mjs +185 -0
- package/lib/integrations/peers/identity.mjs +36 -10
- package/lib/integrations/peers/processes.mjs +22 -8
- package/lib/integrations/providers.mjs +6 -2
- package/lib/mcp/scope.mjs +104 -0
- package/lib/mcp/stdio.mjs +80 -0
- package/lib/mcp/tools.mjs +197 -0
- package/lib/process/identity.mjs +33 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/buffer-directory-lease.mjs +2 -1
- package/lib/runtime/client.mjs +3 -0
- package/lib/runtime/native/client.mjs +38 -0
- package/lib/runtime/native/service.mjs +511 -0
- package/lib/runtime/native/store.mjs +121 -0
- package/lib/shared/file-lock.mjs +11 -4
- package/lib/shared/socket-lock-worker.mjs +73 -24
- package/lib/terminal/tmux.mjs +53 -23
- package/lib/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -2
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +557 -0
- package/lib/web/codex-sessions.mjs +287 -0
- package/lib/web/control-lease.mjs +204 -0
- package/lib/web/http-routes.mjs +197 -15
- package/lib/web/http.mjs +5 -4
- package/lib/web/native-sessions.mjs +286 -0
- package/lib/web/project-contexts.mjs +8 -2
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +101 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +4 -2
- package/lib/web/task-results.mjs +76 -0
- package/lib/web/tmux-sessions.mjs +92 -11
- package/lib/web/tmux-stream.mjs +2 -2
- package/lib/web/ui-codex.mjs +550 -0
- package/lib/web/ui-command-palette.mjs +119 -0
- package/lib/web/ui-handoff.mjs +102 -0
- package/lib/web/ui-history.mjs +118 -0
- package/lib/web/ui-i18n.mjs +841 -0
- package/lib/web/ui-interactions.mjs +78 -0
- package/lib/web/ui-login.mjs +101 -0
- package/lib/web/ui-native.mjs +165 -0
- package/lib/web/ui-preferences.mjs +278 -0
- package/lib/web/ui-review.mjs +132 -0
- package/lib/web/ui-template.mjs +1106 -576
- package/lib/web/ui-terminal-find.mjs +98 -0
- package/lib/web/ui-workbench.mjs +94 -0
- package/lib/web/ui-workspace.mjs +222 -0
- package/package.json +12 -4
- package/scripts/check-free-identifiers.mjs +67 -8
- package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
- package/scripts/dsh-cordis-acceptance.mjs +200 -0
- package/scripts/dsh-installed-acceptance.mjs +247 -0
- package/scripts/dsh-official-acceptance.mjs +180 -0
- package/scripts/dsh-web-startup-acceptance.mjs +87 -0
- package/scripts/github-release.mjs +3 -1
- package/scripts/native-live-acceptance.mjs +369 -0
- package/scripts/regression.mjs +338 -187
- package/scripts/web-handoff-installed-acceptance.mjs +98 -0
- package/scripts/web-native-interaction-acceptance.mjs +204 -0
- package/scripts/web-native-model-acceptance.mjs +138 -0
- package/docs/architecture-split-review.md +0 -217
- package/docs/current-state-review-2026-08.md +0 -341
- package/docs/defect-review-2026-07.md +0 -382
- package/docs/layout-split-stack-audit.md +0 -298
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +0 -104
package/scripts/regression.mjs
CHANGED
|
@@ -26,6 +26,7 @@ const outDir = path.join(os.tmpdir(), `hcc-reg-out-${testId}`);
|
|
|
26
26
|
const tmuxSession = `hcc-reg-${process.pid}`;
|
|
27
27
|
const tmuxSocketName = `hcc-reg-${testId}`.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
28
28
|
const secondProjectRoot = path.join(root, 'second-project');
|
|
29
|
+
const TERMINAL_WEBSOCKET_TIMEOUT_MS = 15_000;
|
|
29
30
|
const realHome = process.env.HOME || os.homedir();
|
|
30
31
|
const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
|
|
31
32
|
const realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
|
|
@@ -432,6 +433,13 @@ async function assertTmuxGcPolicy() {
|
|
|
432
433
|
managedTmuxSessions.add(rootEventSession);
|
|
433
434
|
managedTmuxSessions.add(manualSession);
|
|
434
435
|
|
|
436
|
+
// Create the one runtime-managed authority before the bulk synthetic tmux
|
|
437
|
+
// fixtures. Leaving the runtime up while those sessions are created lets its
|
|
438
|
+
// five-second orphan scan adopt them and contend with this start on slow CI.
|
|
439
|
+
const eventActivePane = parsePane(hcc(['peer', 'start', eventPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
440
|
+
const eventActiveSession = run('tmux', ['display-message', '-p', '-t', eventActivePane, '#{session_name}']).trim();
|
|
441
|
+
await stopRuntime();
|
|
442
|
+
|
|
435
443
|
run('tmux', ['new-session', '-d', '-s', staleSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
436
444
|
run('tmux', ['new-session', '-d', '-s', peerFilterSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
437
445
|
run('tmux', ['new-session', '-d', '-s', attachedSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
@@ -489,10 +497,27 @@ async function assertTmuxGcPolicy() {
|
|
|
489
497
|
insertRuntimeTargetBinding(deadBindingPeer, 'shell', 'tmux-gc-binding-dead-session', deadBindingPane);
|
|
490
498
|
insertRuntimeTargetBinding(reusedBindingPeer, 'shell', 'tmux-gc-binding-reused-session', reusedBindingPane);
|
|
491
499
|
insertRuntimeTargetBinding(clientUnknownBindingPeer, 'shell', 'tmux-gc-binding-client-unknown-session', clientUnknownBindingPane);
|
|
492
|
-
const eventActivePane = parsePane(hcc(['peer', 'start', eventPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
493
|
-
const eventActiveSession = run('tmux', ['display-message', '-p', '-t', eventActivePane, '#{session_name}']).trim();
|
|
494
500
|
withMeshDb((db) => {
|
|
495
501
|
const t = Math.floor(Date.now() / 1000) - 30 * 86400;
|
|
502
|
+
for (const [peer, pane] of [
|
|
503
|
+
[stalePeer, stalePane],
|
|
504
|
+
[peerFilterPeer, peerFilterPane],
|
|
505
|
+
[attachedPeer, attachedPane],
|
|
506
|
+
[deadBindingPeer, deadBindingPane],
|
|
507
|
+
[reusedBindingPeer, reusedBindingPane],
|
|
508
|
+
[clientUnknownBindingPeer, clientUnknownBindingPane]
|
|
509
|
+
]) {
|
|
510
|
+
db.prepare(`
|
|
511
|
+
UPDATE peer_bindings
|
|
512
|
+
SET transport = 'tmux', runtime_target = ?, updated_at = ?
|
|
513
|
+
WHERE peer = ?
|
|
514
|
+
`).run(pane, t, peer);
|
|
515
|
+
}
|
|
516
|
+
db.prepare(`
|
|
517
|
+
UPDATE peer_bindings
|
|
518
|
+
SET transport = 'tmux', runtime_target = ?
|
|
519
|
+
WHERE peer = ?
|
|
520
|
+
`).run(eventActivePane, eventPeer);
|
|
496
521
|
db.prepare('UPDATE peers SET last_seen_at = ? WHERE id IN (?, ?, ?)').run(t, stalePeer, peerFilterPeer, attachedPeer);
|
|
497
522
|
db.prepare("UPDATE peers SET status = 'exited' WHERE id IN (?, ?)").run(stalePeer, peerFilterPeer);
|
|
498
523
|
db.prepare('UPDATE peer_bindings SET updated_at = ? WHERE peer IN (?, ?, ?)').run(t, stalePeer, peerFilterPeer, attachedPeer);
|
|
@@ -1581,32 +1606,82 @@ async function expectWebSocketMarker(peer, marker) {
|
|
|
1581
1606
|
await new Promise((resolve, reject) => {
|
|
1582
1607
|
let sawSnapshot = false;
|
|
1583
1608
|
let sawMarker = false;
|
|
1584
|
-
const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
|
|
1585
|
-
const timer = setTimeout(() =>
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
});
|
|
1609
|
+
const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
|
|
1610
|
+
const timer = setTimeout(() => {
|
|
1611
|
+
try { ws.terminate(); } catch {}
|
|
1612
|
+
reject(new Error(`${peer} websocket timeout`));
|
|
1613
|
+
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1590
1614
|
ws.on('message', (raw) => {
|
|
1591
1615
|
const msg = JSON.parse(String(raw));
|
|
1592
|
-
if (msg.type === 'snapshot')
|
|
1616
|
+
if (msg.type === 'snapshot') {
|
|
1617
|
+
sawSnapshot = true;
|
|
1618
|
+
claimTerminalControl(ws).then(() => ws.send(JSON.stringify({
|
|
1619
|
+
type: 'input', data: `echo ${marker}\r`, action_token: ws.hccActionToken,
|
|
1620
|
+
epoch: ws.hccControl.epoch
|
|
1621
|
+
}))).catch(reject);
|
|
1622
|
+
}
|
|
1593
1623
|
if (['snapshot', 'data', 'replace'].includes(msg.type) && String(msg.data || '').includes(marker)) {
|
|
1594
1624
|
sawMarker = true;
|
|
1595
1625
|
}
|
|
1596
1626
|
if (sawSnapshot && sawMarker) {
|
|
1597
1627
|
clearTimeout(timer);
|
|
1628
|
+
releaseTerminalControl(ws);
|
|
1598
1629
|
ws.close();
|
|
1599
1630
|
resolve();
|
|
1600
1631
|
}
|
|
1601
1632
|
});
|
|
1602
|
-
ws.on('error', reject);
|
|
1633
|
+
ws.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Keep the private token and public fencing epoch on each test connection.
|
|
1638
|
+
// Reconnecting helpers explicitly claim control before an authorized write.
|
|
1639
|
+
function trackTerminalControl(ws) {
|
|
1640
|
+
ws.on('message', (raw) => {
|
|
1641
|
+
let message;
|
|
1642
|
+
try { message = JSON.parse(String(raw)); } catch { return; }
|
|
1643
|
+
if (message.action_token) ws.hccActionToken = message.action_token;
|
|
1644
|
+
if (message.control) ws.hccControl = message.control;
|
|
1645
|
+
});
|
|
1646
|
+
return ws;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function claimTerminalControl(ws) {
|
|
1650
|
+
if (ws.hccControl?.can_control) return ws.hccControl;
|
|
1651
|
+
return new Promise((resolve, reject) => {
|
|
1652
|
+
const timer = setTimeout(() => finish(new Error('terminal control claim timeout')), 5000);
|
|
1653
|
+
const finish = (error = null) => {
|
|
1654
|
+
clearTimeout(timer);
|
|
1655
|
+
ws.off('message', onMessage);
|
|
1656
|
+
if (error) reject(error);
|
|
1657
|
+
else resolve(ws.hccControl);
|
|
1658
|
+
};
|
|
1659
|
+
const onMessage = (raw) => {
|
|
1660
|
+
let message;
|
|
1661
|
+
try { message = JSON.parse(String(raw)); } catch { return; }
|
|
1662
|
+
if (message.type === 'error') finish(new Error(`terminal control claim failed: ${JSON.stringify(message.error)}`));
|
|
1663
|
+
else if (message.type === 'control' && message.control?.can_control) finish();
|
|
1664
|
+
};
|
|
1665
|
+
ws.on('message', onMessage);
|
|
1666
|
+
ws.send(JSON.stringify({ type: 'control', action: 'claim', action_token: ws.hccActionToken,
|
|
1667
|
+
epoch: ws.hccControl?.epoch, force: true }));
|
|
1603
1668
|
});
|
|
1604
1669
|
}
|
|
1605
1670
|
|
|
1671
|
+
function releaseTerminalControl(ws) {
|
|
1672
|
+
if (ws?.readyState === WebSocket.OPEN && ws.hccControl?.can_control) {
|
|
1673
|
+
ws.send(JSON.stringify({ type: 'control', action: 'release', action_token: ws.hccActionToken,
|
|
1674
|
+
epoch: ws.hccControl.epoch }));
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1606
1678
|
async function openTerminalWebSocket(peer) {
|
|
1607
1679
|
return new Promise((resolve, reject) => {
|
|
1608
1680
|
const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
|
|
1609
|
-
const timer = setTimeout(() =>
|
|
1681
|
+
const timer = setTimeout(() => {
|
|
1682
|
+
try { ws.terminate(); } catch {}
|
|
1683
|
+
reject(new Error(`${peer} websocket open timeout`));
|
|
1684
|
+
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1610
1685
|
ws.on('open', () => {
|
|
1611
1686
|
clearTimeout(timer);
|
|
1612
1687
|
resolve(ws);
|
|
@@ -1626,13 +1701,16 @@ async function openSessionActionChannel(peer, params = {}) {
|
|
|
1626
1701
|
for (const [key, value] of Object.entries(params)) {
|
|
1627
1702
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
1628
1703
|
}
|
|
1629
|
-
const ws = new WebSocket(url, runtimeWsOptions());
|
|
1630
|
-
const timer = setTimeout(() =>
|
|
1704
|
+
const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
|
|
1705
|
+
const timer = setTimeout(() => {
|
|
1706
|
+
try { ws.terminate(); } catch {}
|
|
1707
|
+
reject(new Error(`${peer} action token fetch timeout`));
|
|
1708
|
+
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1631
1709
|
ws.on('message', (raw) => {
|
|
1632
1710
|
const msg = JSON.parse(String(raw));
|
|
1633
1711
|
if (msg.type === 'snapshot') {
|
|
1634
1712
|
clearTimeout(timer);
|
|
1635
|
-
resolve({ token: msg.action_token || '', ws });
|
|
1713
|
+
resolve({ token: msg.action_token || '', ws, get epoch() { return ws.hccControl?.epoch; } });
|
|
1636
1714
|
}
|
|
1637
1715
|
});
|
|
1638
1716
|
ws.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
@@ -1643,8 +1721,23 @@ async function fetchSessionActionToken(peer, params = {}) {
|
|
|
1643
1721
|
return (await openSessionActionChannel(peer, params)).token;
|
|
1644
1722
|
}
|
|
1645
1723
|
|
|
1724
|
+
async function stopControlledSession(id, payload = {}, projectRoot = root) {
|
|
1725
|
+
const channel = await openSessionActionChannel(id, { root: projectRoot });
|
|
1726
|
+
try {
|
|
1727
|
+
await claimTerminalControl(channel.ws);
|
|
1728
|
+
return await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, {
|
|
1729
|
+
method: 'POST',
|
|
1730
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1731
|
+
body: JSON.stringify({ ...payload, action_token: channel.token, epoch: channel.epoch })
|
|
1732
|
+
}, { root: projectRoot });
|
|
1733
|
+
} finally {
|
|
1734
|
+
await closeTerminalWebSocket(channel.ws);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1646
1738
|
async function closeTerminalWebSocket(ws) {
|
|
1647
1739
|
if (!ws || ws.readyState === WebSocket.CLOSED) return;
|
|
1740
|
+
releaseTerminalControl(ws);
|
|
1648
1741
|
await new Promise((resolve) => {
|
|
1649
1742
|
const timer = setTimeout(() => {
|
|
1650
1743
|
try { ws.terminate(); } catch {}
|
|
@@ -1667,12 +1760,16 @@ async function fetchTerminalSnapshot(peer, params = {}) {
|
|
|
1667
1760
|
for (const [key, value] of Object.entries(params)) {
|
|
1668
1761
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
1669
1762
|
}
|
|
1670
|
-
const ws = new WebSocket(url, runtimeWsOptions());
|
|
1671
|
-
const timer = setTimeout(() =>
|
|
1763
|
+
const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
|
|
1764
|
+
const timer = setTimeout(() => {
|
|
1765
|
+
try { ws.terminate(); } catch {}
|
|
1766
|
+
reject(new Error(`${peer} terminal snapshot timeout`));
|
|
1767
|
+
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1672
1768
|
ws.on('message', (raw) => {
|
|
1673
1769
|
const msg = JSON.parse(String(raw));
|
|
1674
1770
|
if (msg.type !== 'snapshot') return;
|
|
1675
1771
|
clearTimeout(timer);
|
|
1772
|
+
releaseTerminalControl(ws);
|
|
1676
1773
|
try { ws.close(); } catch {}
|
|
1677
1774
|
resolve(String(msg.data || ''));
|
|
1678
1775
|
});
|
|
@@ -1759,12 +1856,12 @@ async function openCookieTerminalWebSocket(peer, sid, params = {}) {
|
|
|
1759
1856
|
}
|
|
1760
1857
|
return new Promise((resolve, reject) => {
|
|
1761
1858
|
let settled = false;
|
|
1762
|
-
const ws = new WebSocket(url, {
|
|
1859
|
+
const ws = trackTerminalControl(new WebSocket(url, {
|
|
1763
1860
|
headers: {
|
|
1764
1861
|
Cookie: `hcc_sid=${sid}`,
|
|
1765
1862
|
Origin: new URL(baseUrl).origin
|
|
1766
1863
|
}
|
|
1767
|
-
});
|
|
1864
|
+
}));
|
|
1768
1865
|
const timer = setTimeout(() => {
|
|
1769
1866
|
if (settled) return;
|
|
1770
1867
|
settled = true;
|
|
@@ -1885,25 +1982,30 @@ async function assertEvictionClosesCookieWebSocket(peer, params = {}) {
|
|
|
1885
1982
|
async function expectResizeReplaceSnapshot(peer, marker) {
|
|
1886
1983
|
await new Promise((resolve, reject) => {
|
|
1887
1984
|
let sawSnapshot = false;
|
|
1888
|
-
const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
|
|
1889
|
-
const timer = setTimeout(() =>
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
});
|
|
1985
|
+
const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
|
|
1986
|
+
const timer = setTimeout(() => {
|
|
1987
|
+
try { ws.terminate(); } catch {}
|
|
1988
|
+
reject(new Error(`${peer} resize replace timeout`));
|
|
1989
|
+
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1894
1990
|
ws.on('message', (raw) => {
|
|
1895
1991
|
const msg = JSON.parse(String(raw));
|
|
1896
1992
|
if (msg.type === 'snapshot') {
|
|
1897
1993
|
sawSnapshot = true;
|
|
1898
|
-
ws.
|
|
1994
|
+
claimTerminalControl(ws).then(() => {
|
|
1995
|
+
ws.send(JSON.stringify({ type: 'input', data: `echo ${marker}\r`,
|
|
1996
|
+
action_token: ws.hccActionToken, epoch: ws.hccControl.epoch }));
|
|
1997
|
+
ws.send(JSON.stringify({ type: 'resize', cols: 96, rows: 28,
|
|
1998
|
+
action_token: ws.hccActionToken, epoch: ws.hccControl.epoch }));
|
|
1999
|
+
}).catch(reject);
|
|
1899
2000
|
}
|
|
1900
2001
|
if (sawSnapshot && msg.type === 'replace' && String(msg.data || '').includes(marker)) {
|
|
1901
2002
|
clearTimeout(timer);
|
|
2003
|
+
releaseTerminalControl(ws);
|
|
1902
2004
|
ws.close();
|
|
1903
2005
|
resolve();
|
|
1904
2006
|
}
|
|
1905
2007
|
});
|
|
1906
|
-
ws.on('error', reject);
|
|
2008
|
+
ws.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
1907
2009
|
});
|
|
1908
2010
|
}
|
|
1909
2011
|
|
|
@@ -1913,18 +2015,21 @@ async function expectWebSocketInputVisible(peer, marker) {
|
|
|
1913
2015
|
let sent = false;
|
|
1914
2016
|
let sawMarkerAfterInput = false;
|
|
1915
2017
|
let sawFrameAfterInput = false;
|
|
1916
|
-
|
|
1917
|
-
const
|
|
1918
|
-
|
|
2018
|
+
const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
|
|
2019
|
+
const timer = setTimeout(() => {
|
|
2020
|
+
try { ws.terminate(); } catch {}
|
|
2021
|
+
reject(new Error(`${peer} websocket input visibility timeout`));
|
|
2022
|
+
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1919
2023
|
ws.on('message', (raw) => {
|
|
1920
2024
|
const msg = JSON.parse(String(raw));
|
|
1921
2025
|
const data = String(msg.data || '');
|
|
1922
2026
|
if (msg.type === 'snapshot') {
|
|
1923
2027
|
sawSnapshot = true;
|
|
1924
|
-
if (msg.action_token) actionToken = msg.action_token;
|
|
1925
2028
|
if (!sent) {
|
|
1926
2029
|
sent = true;
|
|
1927
|
-
ws.send(JSON.stringify({ type: 'input',
|
|
2030
|
+
claimTerminalControl(ws).then(() => ws.send(JSON.stringify({ type: 'input',
|
|
2031
|
+
data: `echo ${marker}\r`, action_token: ws.hccActionToken,
|
|
2032
|
+
epoch: ws.hccControl.epoch }))).catch(reject);
|
|
1928
2033
|
}
|
|
1929
2034
|
return;
|
|
1930
2035
|
}
|
|
@@ -1934,11 +2039,12 @@ async function expectWebSocketInputVisible(peer, marker) {
|
|
|
1934
2039
|
}
|
|
1935
2040
|
if (sawSnapshot && sawFrameAfterInput && sawMarkerAfterInput) {
|
|
1936
2041
|
clearTimeout(timer);
|
|
2042
|
+
releaseTerminalControl(ws);
|
|
1937
2043
|
ws.close();
|
|
1938
2044
|
resolve();
|
|
1939
2045
|
}
|
|
1940
2046
|
});
|
|
1941
|
-
ws.on('error', reject);
|
|
2047
|
+
ws.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
1942
2048
|
});
|
|
1943
2049
|
}
|
|
1944
2050
|
|
|
@@ -1948,7 +2054,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
1948
2054
|
for (const [key, value] of Object.entries(params)) {
|
|
1949
2055
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
1950
2056
|
}
|
|
1951
|
-
const ws = new WebSocket(url, runtimeWsOptions());
|
|
2057
|
+
const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
|
|
1952
2058
|
const rejectedMarker = `REJECTED_${testId}_${Math.random().toString(16).slice(2)}`;
|
|
1953
2059
|
const acceptedMarker = `ACCEPTED_${testId}_${Math.random().toString(16).slice(2)}`;
|
|
1954
2060
|
let snapshotToken = '';
|
|
@@ -1965,6 +2071,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
1965
2071
|
if (settled) return;
|
|
1966
2072
|
settled = true;
|
|
1967
2073
|
clearTimeout(timer);
|
|
2074
|
+
releaseTerminalControl(ws);
|
|
1968
2075
|
try { ws.close(); } catch {}
|
|
1969
2076
|
if (err) reject(err);
|
|
1970
2077
|
else resolve();
|
|
@@ -1975,7 +2082,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
1975
2082
|
output += String(message.data || '');
|
|
1976
2083
|
if (message.type === 'snapshot' && !snapshotToken) {
|
|
1977
2084
|
snapshotToken = message.action_token || '';
|
|
1978
|
-
const frame = { type: 'input', data: `echo ${rejectedMarker}\r
|
|
2085
|
+
const frame = { type: 'input', data: `echo ${rejectedMarker}\r`, epoch: ws.hccControl.epoch };
|
|
1979
2086
|
if (suppliedToken !== undefined) frame.action_token = suppliedToken;
|
|
1980
2087
|
ws.send(JSON.stringify(frame));
|
|
1981
2088
|
setTimeout(() => {
|
|
@@ -1984,11 +2091,12 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
1984
2091
|
return;
|
|
1985
2092
|
}
|
|
1986
2093
|
checkedRejected = true;
|
|
1987
|
-
ws.send(JSON.stringify({
|
|
2094
|
+
claimTerminalControl(ws).then(() => ws.send(JSON.stringify({
|
|
1988
2095
|
type: 'input',
|
|
1989
2096
|
data: `echo ${acceptedMarker}\r`,
|
|
1990
|
-
action_token: snapshotToken
|
|
1991
|
-
|
|
2097
|
+
action_token: snapshotToken,
|
|
2098
|
+
epoch: ws.hccControl.epoch
|
|
2099
|
+
}))).catch(finish);
|
|
1992
2100
|
}, 350);
|
|
1993
2101
|
}
|
|
1994
2102
|
if (checkedRejected && output.includes(acceptedMarker)) finish();
|
|
@@ -2495,7 +2603,8 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2495
2603
|
ws.send(JSON.stringify({
|
|
2496
2604
|
type: 'input',
|
|
2497
2605
|
data: `echo ${marker}\r`,
|
|
2498
|
-
action_token: ws.hccActionToken
|
|
2606
|
+
action_token: ws.hccActionToken,
|
|
2607
|
+
epoch: ws.hccControl.epoch
|
|
2499
2608
|
}));
|
|
2500
2609
|
const closeResult = await closed;
|
|
2501
2610
|
if (closeResult.code !== 4001 || !closeResult.reason.includes('session expired')) {
|
|
@@ -2515,7 +2624,7 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2515
2624
|
try { ws.terminate(); } catch {}
|
|
2516
2625
|
}
|
|
2517
2626
|
try {
|
|
2518
|
-
await
|
|
2627
|
+
await stopControlledSession(expirySessionId);
|
|
2519
2628
|
} catch {}
|
|
2520
2629
|
await stopRuntime();
|
|
2521
2630
|
}
|
|
@@ -2564,7 +2673,8 @@ async function webSecretRedactionWorkflow() {
|
|
|
2564
2673
|
}, { root });
|
|
2565
2674
|
if (!create.ok) fail(`redaction PTY create failed with status ${create.status}`);
|
|
2566
2675
|
terminalWs = await openCookieTerminalWebSocket(sessionId, sid, { root });
|
|
2567
|
-
terminalWs.send(JSON.stringify({ type: 'resize', cols: 90, rows: 28
|
|
2676
|
+
terminalWs.send(JSON.stringify({ type: 'resize', cols: 90, rows: 28,
|
|
2677
|
+
action_token: terminalWs.hccActionToken, epoch: terminalWs.hccControl.epoch }));
|
|
2568
2678
|
|
|
2569
2679
|
const badProject = await fetch(new URL(`/api/projects?token=${encodeURIComponent(secret)}`, runtime.base_url), {
|
|
2570
2680
|
method: 'POST',
|
|
@@ -2609,7 +2719,7 @@ async function webSecretRedactionWorkflow() {
|
|
|
2609
2719
|
try { terminalWs.terminate(); } catch {}
|
|
2610
2720
|
}
|
|
2611
2721
|
try {
|
|
2612
|
-
await
|
|
2722
|
+
await stopControlledSession(sessionId);
|
|
2613
2723
|
} catch {}
|
|
2614
2724
|
await stopRuntime();
|
|
2615
2725
|
}
|
|
@@ -4031,6 +4141,10 @@ async function multiProjectWebWorkflow() {
|
|
|
4031
4141
|
!arbitraryRuntime.process_identity?.commandHash) {
|
|
4032
4142
|
fail(`project API runtime pointer omitted the shared runtime fingerprint:\n${JSON.stringify(arbitraryRuntime, null, 2)}`);
|
|
4033
4143
|
}
|
|
4144
|
+
const arbitraryBufs = path.join(arbitraryRoot, '.hello-cc', 'bufs');
|
|
4145
|
+
if (fs.existsSync(arbitraryBufs)) {
|
|
4146
|
+
fail('project registration eagerly created the optional buffer directory');
|
|
4147
|
+
}
|
|
4034
4148
|
fs.rmSync(path.join(arbitraryRoot, '.hello-cc'), { recursive: true, force: true });
|
|
4035
4149
|
await sleep(1250);
|
|
4036
4150
|
if (fs.existsSync(path.join(arbitraryRoot, '.hello-cc'))) {
|
|
@@ -4322,17 +4436,16 @@ async function multiProjectWebWorkflow() {
|
|
|
4322
4436
|
}
|
|
4323
4437
|
for (const expected of [
|
|
4324
4438
|
'id="langSelect"',
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
"
|
|
4329
|
-
"
|
|
4330
|
-
"
|
|
4331
|
-
"
|
|
4332
|
-
"
|
|
4333
|
-
"
|
|
4334
|
-
"
|
|
4335
|
-
"'status.active': '活跃'",
|
|
4439
|
+
'window.hccUi',
|
|
4440
|
+
'hccUi.language',
|
|
4441
|
+
'hccUi.tr(key',
|
|
4442
|
+
'"language":"语言"',
|
|
4443
|
+
'"projectState":"项目状态"',
|
|
4444
|
+
'"noSessionSelected":"未选择会话"',
|
|
4445
|
+
'"sendMessage":"发送消息"',
|
|
4446
|
+
'"peers":"协作方"',
|
|
4447
|
+
'"noPeers":"没有协作方。"',
|
|
4448
|
+
'"status.active":"活跃"',
|
|
4336
4449
|
"data-i18n=\"language\"",
|
|
4337
4450
|
"data-i18n-placeholder=\"projectPathPlaceholder\"",
|
|
4338
4451
|
"data-i18n-title=\"collapseSidebar\"",
|
|
@@ -4379,7 +4492,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4379
4492
|
'setPointerCapture',
|
|
4380
4493
|
'sideIsCollapsed(opposite) ? 0',
|
|
4381
4494
|
'Math.abs(delta) <= 3',
|
|
4382
|
-
"
|
|
4495
|
+
"hccUi.safeSet('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
|
|
4383
4496
|
'cursor: col-resize'
|
|
4384
4497
|
]) {
|
|
4385
4498
|
if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
|
|
@@ -4389,13 +4502,13 @@ async function multiProjectWebWorkflow() {
|
|
|
4389
4502
|
}
|
|
4390
4503
|
for (const expected of [
|
|
4391
4504
|
'function stateCardCollapsed(section)',
|
|
4392
|
-
"
|
|
4505
|
+
"hccUi.safeGet('hcc.stateCard.' + section + '.collapsed')",
|
|
4393
4506
|
'function stateCardHtml(section, title, count, bodyHtml)',
|
|
4394
4507
|
'state-card-toggle',
|
|
4395
4508
|
'aria-expanded=',
|
|
4396
4509
|
'state-card-collapsed',
|
|
4397
4510
|
'function bindStateCardToggles()',
|
|
4398
|
-
"
|
|
4511
|
+
"hccUi.safeSet('hcc.stateCard.' + section + '.collapsed'",
|
|
4399
4512
|
"stateCardHtml('automation'",
|
|
4400
4513
|
"stateCardHtml('timeline'",
|
|
4401
4514
|
"stateCardHtml('messages'",
|
|
@@ -4427,11 +4540,18 @@ async function multiProjectWebWorkflow() {
|
|
|
4427
4540
|
for (const expected of [
|
|
4428
4541
|
'term.onData((data) => {',
|
|
4429
4542
|
'sendTerminalInput(data);',
|
|
4430
|
-
|
|
4431
|
-
|
|
4543
|
+
"function sendTerminalInput(data, inputId = '')",
|
|
4544
|
+
'epoch: controlEpoch()',
|
|
4545
|
+
"...(inputId ? { input_id: inputId } : {})",
|
|
4546
|
+
"msg.type === 'input_ack' && handoffStore.acknowledge(currentProject, id, msg.input_id)",
|
|
4547
|
+
'handoffStore.uncertain(currentProject, id)',
|
|
4548
|
+
'Connection recovery never replays terminal input or a draft.'
|
|
4432
4549
|
]) {
|
|
4433
4550
|
if (!html.includes(expected)) fail(`web terminal input forwarding missing: ${expected}`);
|
|
4434
4551
|
}
|
|
4552
|
+
if (html.includes('flushPendingTerminalInput') || html.includes('pendingTerminalInput')) {
|
|
4553
|
+
fail('web terminal recovery must not buffer or replay terminal input');
|
|
4554
|
+
}
|
|
4435
4555
|
if (!html.includes("stateCardHtml('timeline'") || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
|
|
4436
4556
|
fail('web state panel missing collaboration timeline or refresh routing');
|
|
4437
4557
|
}
|
|
@@ -4483,14 +4603,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4483
4603
|
return json.session;
|
|
4484
4604
|
};
|
|
4485
4605
|
const stopSession = async (id, payload = null, projectRoot = root) => {
|
|
4486
|
-
const
|
|
4487
|
-
? {
|
|
4488
|
-
method: 'POST',
|
|
4489
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4490
|
-
body: JSON.stringify(payload)
|
|
4491
|
-
}
|
|
4492
|
-
: { method: 'POST' };
|
|
4493
|
-
const response = await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, options, { root: projectRoot });
|
|
4606
|
+
const response = await stopControlledSession(id, payload || {}, projectRoot);
|
|
4494
4607
|
const json = await response.json();
|
|
4495
4608
|
if (!response.ok) fail(`web provider session stop failed: ${JSON.stringify(json)}`);
|
|
4496
4609
|
return json.session;
|
|
@@ -4638,7 +4751,8 @@ async function multiProjectWebWorkflow() {
|
|
|
4638
4751
|
const inputResponse = await cookieRuntimeFetch(inputRoute, cookieAdmin, {
|
|
4639
4752
|
method: 'POST',
|
|
4640
4753
|
headers: { 'Content-Type': 'application/json' },
|
|
4641
|
-
body: JSON.stringify({ data: `echo ${marker}\r
|
|
4754
|
+
body: JSON.stringify({ data: `echo ${marker}\r`, action_token: cookieAdminWs.hccActionToken,
|
|
4755
|
+
epoch: cookieAdminWs.hccControl.epoch })
|
|
4642
4756
|
}, { root });
|
|
4643
4757
|
if (!inputResponse.ok) fail(`same-origin cookie administrator input returned ${inputResponse.status}`);
|
|
4644
4758
|
});
|
|
@@ -4661,7 +4775,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4661
4775
|
const cookieStop = await cookieRuntimeFetch(stopRoute, cookieAdmin, {
|
|
4662
4776
|
method: 'POST',
|
|
4663
4777
|
headers: { 'Content-Type': 'application/json' },
|
|
4664
|
-
body:
|
|
4778
|
+
body: JSON.stringify({ action_token: cookieAdminWs.hccActionToken, epoch: cookieAdminWs.hccControl.epoch })
|
|
4665
4779
|
}, { root });
|
|
4666
4780
|
if (!cookieStop.ok) fail(`same-origin cookie administrator stop returned ${cookieStop.status}`);
|
|
4667
4781
|
} finally {
|
|
@@ -4758,14 +4872,16 @@ async function multiProjectWebWorkflow() {
|
|
|
4758
4872
|
kind: 'shell',
|
|
4759
4873
|
command: 'bash --noprofile --norc'
|
|
4760
4874
|
});
|
|
4761
|
-
const
|
|
4875
|
+
const encodedActionChannel = await openSessionActionChannel(encodedActionId, { root });
|
|
4876
|
+
const encodedActionToken = encodedActionChannel.token;
|
|
4762
4877
|
const siblingActionId = `web-action-sibling-${testId}`;
|
|
4763
4878
|
const siblingActionSession = await startProvider({
|
|
4764
4879
|
id: siblingActionId,
|
|
4765
4880
|
kind: 'shell',
|
|
4766
4881
|
command: 'bash --noprofile --norc'
|
|
4767
4882
|
}, otherRoot);
|
|
4768
|
-
const
|
|
4883
|
+
const siblingActionChannel = await openSessionActionChannel(siblingActionId, { root: otherRoot });
|
|
4884
|
+
const siblingActionToken = siblingActionChannel.token;
|
|
4769
4885
|
if (!encodedActionToken || !siblingActionToken) {
|
|
4770
4886
|
fail(`alternate sessions did not deliver action tokens:\n${JSON.stringify({ encodedActionSession, siblingActionSession }, null, 2)}`);
|
|
4771
4887
|
}
|
|
@@ -4798,10 +4914,12 @@ async function multiProjectWebWorkflow() {
|
|
|
4798
4914
|
}
|
|
4799
4915
|
await closeTerminalWebSocket(secondManagedChannel.ws);
|
|
4800
4916
|
await expectActionTokenRejected(secondManagedChannel.token, 'closed-connection');
|
|
4917
|
+
await claimTerminalControl(managedActionChannel.ws);
|
|
4918
|
+
await claimTerminalControl(encodedActionChannel.ws);
|
|
4801
4919
|
const encodedPeerAction = await runtimeFetch(`/api/peers/${encodeURIComponent(encodedActionId)}/actions/heartbeat`, {
|
|
4802
4920
|
method: 'POST',
|
|
4803
4921
|
headers: { 'Content-Type': 'application/json' },
|
|
4804
|
-
body: JSON.stringify({ action_token: encodedActionToken, renew_locks: false })
|
|
4922
|
+
body: JSON.stringify({ action_token: encodedActionToken, epoch: encodedActionChannel.epoch, renew_locks: false })
|
|
4805
4923
|
}, { root });
|
|
4806
4924
|
if (!encodedPeerAction.ok) {
|
|
4807
4925
|
fail(`URL-encoded peer action rejected its own token:\n${await encodedPeerAction.text()}`);
|
|
@@ -4810,11 +4928,13 @@ async function multiProjectWebWorkflow() {
|
|
|
4810
4928
|
const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
|
|
4811
4929
|
method: 'POST',
|
|
4812
4930
|
headers: { 'Content-Type': 'application/json' },
|
|
4813
|
-
body: JSON.stringify({ action_token: managedActionToken })
|
|
4931
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch })
|
|
4814
4932
|
}, { root })).json();
|
|
4815
4933
|
if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
|
|
4816
4934
|
fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
|
|
4817
4935
|
}
|
|
4936
|
+
await closeTerminalWebSocket(encodedActionChannel.ws);
|
|
4937
|
+
await closeTerminalWebSocket(siblingActionChannel.ws);
|
|
4818
4938
|
await stopSession(encodedActionSession.id);
|
|
4819
4939
|
await stopSession(siblingActionSession.id, null, otherRoot);
|
|
4820
4940
|
// Cookie-authenticated terminal sockets are tied to the opaque browser
|
|
@@ -4828,7 +4948,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4828
4948
|
method: 'POST',
|
|
4829
4949
|
headers: { 'Content-Type': 'application/json' },
|
|
4830
4950
|
body: JSON.stringify({
|
|
4831
|
-
action_token: managedActionToken,
|
|
4951
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
4832
4952
|
resource: webTtlResource,
|
|
4833
4953
|
task: Number(taskMatch[1]),
|
|
4834
4954
|
ttl: 60
|
|
@@ -4846,7 +4966,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4846
4966
|
method: 'POST',
|
|
4847
4967
|
headers: { 'Content-Type': 'application/json' },
|
|
4848
4968
|
body: JSON.stringify({
|
|
4849
|
-
action_token: managedActionToken,
|
|
4969
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
4850
4970
|
renew_locks: true,
|
|
4851
4971
|
...(ttlOverride === null ? {} : { ttl: ttlOverride })
|
|
4852
4972
|
})
|
|
@@ -4890,7 +5010,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4890
5010
|
const unknownHeartbeatResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/heartbeat`, {
|
|
4891
5011
|
method: 'POST',
|
|
4892
5012
|
headers: { 'Content-Type': 'application/json' },
|
|
4893
|
-
body: JSON.stringify({ action_token: managedActionToken, renew_locks: true })
|
|
5013
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, renew_locks: true })
|
|
4894
5014
|
}, { root });
|
|
4895
5015
|
const unknownHeartbeat = await unknownHeartbeatResponse.json();
|
|
4896
5016
|
if (!unknownHeartbeatResponse.ok || unknownHeartbeat.data?.renewed !== 0) {
|
|
@@ -4906,7 +5026,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4906
5026
|
const graceHeartbeatResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/heartbeat`, {
|
|
4907
5027
|
method: 'POST',
|
|
4908
5028
|
headers: { 'Content-Type': 'application/json' },
|
|
4909
|
-
body: JSON.stringify({ action_token: managedActionToken, renew_locks: true })
|
|
5029
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, renew_locks: true })
|
|
4910
5030
|
}, { root });
|
|
4911
5031
|
const graceHeartbeat = await graceHeartbeatResponse.json();
|
|
4912
5032
|
const graceHeartbeatState = withMeshDb((db) => ({
|
|
@@ -4986,7 +5106,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4986
5106
|
method: 'POST',
|
|
4987
5107
|
headers: { 'Content-Type': 'application/json' },
|
|
4988
5108
|
body: JSON.stringify({
|
|
4989
|
-
action_token: managedActionToken,
|
|
5109
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
4990
5110
|
id: evidenceTaskId,
|
|
4991
5111
|
reason: 'owner process identity was reused',
|
|
4992
5112
|
policy: 'stale',
|
|
@@ -5047,7 +5167,7 @@ async function multiProjectWebWorkflow() {
|
|
|
5047
5167
|
const liveLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/lock-acquire`, {
|
|
5048
5168
|
method: 'POST',
|
|
5049
5169
|
headers: { 'Content-Type': 'application/json' },
|
|
5050
|
-
body: JSON.stringify({ action_token: managedActionToken, resource: liveExpiredResource, ttl: 60 })
|
|
5170
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, resource: liveExpiredResource, ttl: 60 })
|
|
5051
5171
|
}, { root });
|
|
5052
5172
|
const liveLockResult = await liveLockResponse.json();
|
|
5053
5173
|
if (liveLockResponse.ok || liveLockResult.error?.code !== 'LOCK_HELD') {
|
|
@@ -5075,7 +5195,7 @@ async function multiProjectWebWorkflow() {
|
|
|
5075
5195
|
const deadLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/lock-acquire`, {
|
|
5076
5196
|
method: 'POST',
|
|
5077
5197
|
headers: { 'Content-Type': 'application/json' },
|
|
5078
|
-
body: JSON.stringify({ action_token: managedActionToken, resource: deadLockResource, ttl: 60 })
|
|
5198
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, resource: deadLockResource, ttl: 60 })
|
|
5079
5199
|
}, { root });
|
|
5080
5200
|
const deadLockResult = await deadLockResponse.json();
|
|
5081
5201
|
if (!deadLockResponse.ok || !deadLockResult.ok || deadLockResult.data?.lock?.owner !== managedActionPeer) {
|
|
@@ -5104,7 +5224,7 @@ async function multiProjectWebWorkflow() {
|
|
|
5104
5224
|
method: 'POST',
|
|
5105
5225
|
headers: { 'Content-Type': 'application/json' },
|
|
5106
5226
|
body: JSON.stringify({
|
|
5107
|
-
action_token: managedActionToken,
|
|
5227
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
5108
5228
|
resource: 'web/identity-spoof-lock',
|
|
5109
5229
|
task: Number(ownerTaskMatch[1])
|
|
5110
5230
|
})
|
|
@@ -5277,50 +5397,6 @@ async function statusServer(status) {
|
|
|
5277
5397
|
};
|
|
5278
5398
|
}
|
|
5279
5399
|
|
|
5280
|
-
async function stalledRuntimeServer(phase) {
|
|
5281
|
-
const child = spawn(process.execPath, ['-e', `
|
|
5282
|
-
const http = require('node:http');
|
|
5283
|
-
const sockets = new Set();
|
|
5284
|
-
const server = http.createServer((req, res) => {
|
|
5285
|
-
if (${JSON.stringify(phase)} === 'body') {
|
|
5286
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5287
|
-
res.write('{"deleted":0');
|
|
5288
|
-
}
|
|
5289
|
-
});
|
|
5290
|
-
server.on('connection', (socket) => {
|
|
5291
|
-
sockets.add(socket);
|
|
5292
|
-
socket.on('close', () => sockets.delete(socket));
|
|
5293
|
-
});
|
|
5294
|
-
server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port) + '\\n'));
|
|
5295
|
-
process.on('SIGTERM', () => {
|
|
5296
|
-
for (const socket of sockets) socket.destroy();
|
|
5297
|
-
server.close(() => process.exit(0));
|
|
5298
|
-
});
|
|
5299
|
-
`], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
5300
|
-
const port = await new Promise((resolve, reject) => {
|
|
5301
|
-
let output = '';
|
|
5302
|
-
const timer = setTimeout(() => reject(new Error(`timed out starting ${phase} stalled runtime`)), 5000);
|
|
5303
|
-
child.stdout.on('data', (chunk) => {
|
|
5304
|
-
output += chunk;
|
|
5305
|
-
const match = output.match(/^(\d+)\n/);
|
|
5306
|
-
if (!match) return;
|
|
5307
|
-
clearTimeout(timer);
|
|
5308
|
-
resolve(Number(match[1]));
|
|
5309
|
-
});
|
|
5310
|
-
child.once('exit', (code) => {
|
|
5311
|
-
clearTimeout(timer);
|
|
5312
|
-
reject(new Error(`${phase} stalled runtime exited early with ${code}`));
|
|
5313
|
-
});
|
|
5314
|
-
});
|
|
5315
|
-
return {
|
|
5316
|
-
baseUrl: `http://127.0.0.1:${port}`,
|
|
5317
|
-
close: async () => {
|
|
5318
|
-
child.kill('SIGTERM');
|
|
5319
|
-
try { await waitForProcessExit(child.pid, `${phase} stalled runtime exit`, 3000); } catch {}
|
|
5320
|
-
}
|
|
5321
|
-
};
|
|
5322
|
-
}
|
|
5323
|
-
|
|
5324
5400
|
function bufferGcCanonicalizationRaceWorkflow() {
|
|
5325
5401
|
for (const phase of ['before', 'after']) {
|
|
5326
5402
|
const raceRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-buffer-gc-realpath-${phase}-${testId}-`));
|
|
@@ -5445,7 +5521,10 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5445
5521
|
if (!firstApply.ok || replayApply.ok) fail('buffer GC token was not exactly once');
|
|
5446
5522
|
|
|
5447
5523
|
const cutoffMs = Date.now() - 60_000;
|
|
5448
|
-
|
|
5524
|
+
// Ordinary deletion checks use a non-zero retention window so crossing a
|
|
5525
|
+
// wall-clock second cannot legitimately activate clock-jump protection.
|
|
5526
|
+
const stableGcRetentionDays = 1;
|
|
5527
|
+
const oldTime = new Date(Date.now() - 2 * 86400_000);
|
|
5449
5528
|
const rootBufs = path.join(root, '.hello-cc', 'bufs');
|
|
5450
5529
|
const siblingBufs = path.join(secondProjectRoot, '.hello-cc', 'bufs');
|
|
5451
5530
|
fs.mkdirSync(rootBufs, { recursive: true });
|
|
@@ -5586,7 +5665,9 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5586
5665
|
const parentAlias = path.join(aliasContainer, 'parent');
|
|
5587
5666
|
fs.symlinkSync(path.dirname(secondProjectRoot), parentAlias);
|
|
5588
5667
|
const siblingAliasRoot = path.join(parentAlias, path.basename(secondProjectRoot));
|
|
5589
|
-
const gcOutput = hccFrom([
|
|
5668
|
+
const gcOutput = hccFrom([
|
|
5669
|
+
'--json', 'gc', '--older-than', String(stableGcRetentionDays), '--yes'
|
|
5670
|
+
], siblingAliasRoot);
|
|
5590
5671
|
fs.rmSync(aliasContainer, { recursive: true, force: true });
|
|
5591
5672
|
const gcPayload = JSON.parse(gcOutput);
|
|
5592
5673
|
const result = gcPayload.data || {};
|
|
@@ -5609,7 +5690,9 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5609
5690
|
} finally {
|
|
5610
5691
|
rootGcClockDb.close();
|
|
5611
5692
|
}
|
|
5612
|
-
const rootGcPayload = JSON.parse(hcc([
|
|
5693
|
+
const rootGcPayload = JSON.parse(hcc([
|
|
5694
|
+
'--json', 'gc', '--older-than', String(stableGcRetentionDays), '--yes'
|
|
5695
|
+
]));
|
|
5613
5696
|
const rootGcResult = rootGcPayload.data || {};
|
|
5614
5697
|
if (rootGcResult.buf_files !== 1 || rootGcResult.protected_buf_files < liveFiles.length ||
|
|
5615
5698
|
rootGcResult.deferred_buf_files < legacyFiles.length || fs.existsSync(rootOrphan)) {
|
|
@@ -5735,7 +5818,9 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5735
5818
|
const normalOrphan = path.join(siblingBufs, `gc-actual-dir-normal-${testId}.out`);
|
|
5736
5819
|
fs.writeFileSync(normalOrphan, 'actual directory normal baseline');
|
|
5737
5820
|
fs.utimesSync(normalOrphan, oldTime, oldTime);
|
|
5738
|
-
const normalGc = JSON.parse(hccFrom([
|
|
5821
|
+
const normalGc = JSON.parse(hccFrom([
|
|
5822
|
+
'--json', 'gc', '--older-than', String(stableGcRetentionDays), '--yes'
|
|
5823
|
+
], secondProjectRoot)).data;
|
|
5739
5824
|
if (fs.existsSync(gapOrphan) || fs.existsSync(normalOrphan) || Number(normalGc.buf_files || 0) < 2) {
|
|
5740
5825
|
fail(`actual runtime buffer directory did not delete against a live baseline: ${JSON.stringify(normalGc)}`);
|
|
5741
5826
|
}
|
|
@@ -5812,42 +5897,6 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5812
5897
|
}
|
|
5813
5898
|
}
|
|
5814
5899
|
|
|
5815
|
-
for (const phase of ['headers', 'body']) {
|
|
5816
|
-
const isolatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-buffer-gc-stall-${phase}-${testId}-`));
|
|
5817
|
-
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-buffer-gc-stall-home-${phase}-${testId}-`));
|
|
5818
|
-
let server = null;
|
|
5819
|
-
try {
|
|
5820
|
-
const isolatedEnv = { ...env, HOME: isolatedHome };
|
|
5821
|
-
run(process.execPath, [hccBin, '--root', isolatedRoot, 'init', '--no-guidance'], { env: isolatedEnv });
|
|
5822
|
-
const directory = path.join(isolatedRoot, '.hello-cc', 'bufs');
|
|
5823
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
5824
|
-
const orphan = path.join(directory, 'old-orphan.out');
|
|
5825
|
-
fs.writeFileSync(orphan, 'orphan');
|
|
5826
|
-
fs.utimesSync(orphan, oldTime, oldTime);
|
|
5827
|
-
server = await stalledRuntimeServer(phase);
|
|
5828
|
-
fs.writeFileSync(path.join(isolatedRoot, '.hello-cc', 'runtime.json'), JSON.stringify({
|
|
5829
|
-
pid: process.pid,
|
|
5830
|
-
base_url: server.baseUrl,
|
|
5831
|
-
token: 'regression-token'
|
|
5832
|
-
}));
|
|
5833
|
-
|
|
5834
|
-
const startedAt = Date.now();
|
|
5835
|
-
const gc = run(process.execPath, [hccBin, '--root', isolatedRoot, '--json', 'gc', '--older-than', '0', '--yes'], { env: isolatedEnv });
|
|
5836
|
-
const elapsedMs = Date.now() - startedAt;
|
|
5837
|
-
const payload = JSON.parse(gc);
|
|
5838
|
-
if (elapsedMs >= 9000 ||
|
|
5839
|
-
Number(payload.data?.buf_files || 0) !== 0 ||
|
|
5840
|
-
Number(payload.data?.deferred_buf_files || 0) < 1 ||
|
|
5841
|
-
!fs.existsSync(orphan)) {
|
|
5842
|
-
fail(`runtime ${phase} stall did not fail closed within deadline (${elapsedMs}ms): ${gc}`);
|
|
5843
|
-
}
|
|
5844
|
-
} finally {
|
|
5845
|
-
await server?.close();
|
|
5846
|
-
fs.rmSync(isolatedRoot, { recursive: true, force: true });
|
|
5847
|
-
fs.rmSync(isolatedHome, { recursive: true, force: true });
|
|
5848
|
-
}
|
|
5849
|
-
}
|
|
5850
|
-
|
|
5851
5900
|
const localRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-buffer-gc-local-${testId}-`));
|
|
5852
5901
|
const localHome = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-buffer-gc-local-home-${testId}-`));
|
|
5853
5902
|
try {
|
|
@@ -5991,9 +6040,24 @@ async function tmuxBackedStartWorkflow() {
|
|
|
5991
6040
|
if (!restoredAuthority?.payload?.tmux_session_created || !restoredAuthority?.payload?.tmux_session_id) {
|
|
5992
6041
|
fail(`startup auto GC left no complete immutable tmux authority: ${JSON.stringify({ authorityBindingAfterStop, restoredAuthority })}`);
|
|
5993
6042
|
}
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
6043
|
+
const authorityGcNow = Math.floor(Date.now() / 1000);
|
|
6044
|
+
withMeshDb((db) => {
|
|
6045
|
+
db.prepare('UPDATE events SET created_at = ? WHERE id = ?')
|
|
6046
|
+
.run(oldAuthorityAt, restoredAuthority.id);
|
|
6047
|
+
// Backdating the fixture must not itself look like a real wall-clock jump.
|
|
6048
|
+
// Establish the same current baseline used by the other explicit GC tests.
|
|
6049
|
+
db.prepare("DELETE FROM meta WHERE key IN ('clock_grace_until', 'clock_pending_gap')").run();
|
|
6050
|
+
db.prepare(`
|
|
6051
|
+
INSERT INTO meta(key, value) VALUES ('clock_last_observed_at', ?)
|
|
6052
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
6053
|
+
`).run(String(authorityGcNow));
|
|
6054
|
+
});
|
|
6055
|
+
// This assertion exercises manual history pruning. Request it explicitly
|
|
6056
|
+
// instead of racing the runtime's asynchronous startup auto-GC.
|
|
6057
|
+
const authorityGc = JSON.parse(hcc(['--json', 'gc', '--older-than', '14', '--history', '--yes']));
|
|
6058
|
+
if (authorityGc.data?.deferred_age_based || Number(authorityGc.data?.deferred_old_events || 0) !== 0) {
|
|
6059
|
+
fail(`tmux authority history GC was unexpectedly deferred: ${JSON.stringify(authorityGc.data)}`);
|
|
6060
|
+
}
|
|
5997
6061
|
const authorityRowsAfterFullGc = withMeshDb((db) => db.prepare(`
|
|
5998
6062
|
SELECT id FROM events
|
|
5999
6063
|
WHERE type = 'tmux.session.attached'
|
|
@@ -6476,10 +6540,36 @@ function identityEnforcementWorkflow() {
|
|
|
6476
6540
|
db2.close();
|
|
6477
6541
|
}
|
|
6478
6542
|
|
|
6479
|
-
|
|
6543
|
+
const readRunOwner = () => {
|
|
6544
|
+
const ownerDb = new DatabaseSync(dbPath);
|
|
6545
|
+
try {
|
|
6546
|
+
return {
|
|
6547
|
+
peer: ownerDb.prepare('SELECT * FROM peers WHERE id = ?').get('real-peer'),
|
|
6548
|
+
binding: ownerDb.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get('real-peer') || null,
|
|
6549
|
+
runEvents: ownerDb.prepare("SELECT * FROM events WHERE actor = 'real-peer' AND type LIKE 'run.session.%' ORDER BY id").all()
|
|
6550
|
+
};
|
|
6551
|
+
} finally {
|
|
6552
|
+
ownerDb.close();
|
|
6553
|
+
}
|
|
6554
|
+
};
|
|
6555
|
+
// Earlier identity commands registered real-peer with the live regression
|
|
6556
|
+
// parent as owner. Correcting --peer must preserve that occupied identity.
|
|
6557
|
+
const ownerBeforeRun = readRunOwner();
|
|
6558
|
+
const occupiedRun = hccFromMaybe(['--json', 'run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: identityEnv });
|
|
6559
|
+
let occupiedError;
|
|
6560
|
+
try { occupiedError = JSON.parse(occupiedRun.stderr).error; } catch {}
|
|
6561
|
+
if (occupiedRun.status === 0 || occupiedError?.code !== 'PEER_SESSION_EXISTS' || occupiedError?.peer !== 'real-peer') {
|
|
6562
|
+
fail(`run did not protect the existing system peer owner:\nstdout=${occupiedRun.stdout}\nstderr=${occupiedRun.stderr}`);
|
|
6563
|
+
}
|
|
6564
|
+
if (JSON.stringify(readRunOwner()) !== JSON.stringify(ownerBeforeRun)) {
|
|
6565
|
+
fail('rejected run changed the existing system peer, binding, or run events');
|
|
6566
|
+
}
|
|
6567
|
+
|
|
6568
|
+
const runnerEnv = { ...identityEnv, HCC_PEER: 'real-runner' };
|
|
6569
|
+
hccFrom(['run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: runnerEnv });
|
|
6480
6570
|
const db3 = new DatabaseSync(dbPath);
|
|
6481
6571
|
try {
|
|
6482
|
-
const realRunner = db3.prepare('SELECT id, status FROM peers WHERE id = ?').get('real-
|
|
6572
|
+
const realRunner = db3.prepare('SELECT id, status FROM peers WHERE id = ?').get('real-runner');
|
|
6483
6573
|
const fakeRunner = db3.prepare('SELECT id FROM peers WHERE id = ?').get('fake-runner');
|
|
6484
6574
|
if (!realRunner || realRunner.status !== 'exited' || fakeRunner) {
|
|
6485
6575
|
fail(`run did not enforce system peer identity:\n${JSON.stringify({ realRunner, fakeRunner }, null, 2)}`);
|
|
@@ -6487,6 +6577,9 @@ function identityEnforcementWorkflow() {
|
|
|
6487
6577
|
} finally {
|
|
6488
6578
|
db3.close();
|
|
6489
6579
|
}
|
|
6580
|
+
if (JSON.stringify(readRunOwner()) !== JSON.stringify(ownerBeforeRun)) {
|
|
6581
|
+
fail('fresh run changed the occupied system peer owner');
|
|
6582
|
+
}
|
|
6490
6583
|
|
|
6491
6584
|
hccFrom(['peer', 'stop', 'fake-target'], identityRoot, { env: identityEnv });
|
|
6492
6585
|
const stopRequested = eventPayloads('peer.stop.requested', 5, dbPath)
|
|
@@ -6758,21 +6851,28 @@ async function syntaxAndHelp() {
|
|
|
6758
6851
|
'function detectedPeerCanStop(peer)',
|
|
6759
6852
|
"if (['exited', 'detached'].includes(status)) return false;",
|
|
6760
6853
|
'const canStop = detectedPeerCanStop(p);',
|
|
6761
|
-
|
|
6854
|
+
"${dshCoordinationPeer(p) ? '' : canStop ?",
|
|
6762
6855
|
'function providerSessionKnown(session)',
|
|
6763
6856
|
"tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'))",
|
|
6764
|
-
'
|
|
6857
|
+
"sessionDetailsHtml('managed:' + s.id",
|
|
6858
|
+
"[tr('runtime'), sessionRuntimeTarget(s)]",
|
|
6859
|
+
"[tr('providerSession'), sessionProvider(s) + ':' + (sessionProviderSessionValue(s) || tr('unknown'))]",
|
|
6860
|
+
"[tr('command'), s.command]",
|
|
6861
|
+
"[tr('cwd'), path]",
|
|
6765
6862
|
'const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));',
|
|
6766
6863
|
'const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));',
|
|
6767
|
-
"
|
|
6864
|
+
"hccUi.safeSet('hcc.showStaleDetected'",
|
|
6768
6865
|
'id="toggleStaleDetected"',
|
|
6769
|
-
"if (e.target.closest('[data-action]')) return;",
|
|
6866
|
+
"if (e.target.closest('details, [data-action]')) return;",
|
|
6770
6867
|
'id="stopKillLabel" data-i18n="dialog.killTmux"',
|
|
6771
6868
|
'id="stopCancelBtn" type="button" data-i18n="dialog.cancel"',
|
|
6772
6869
|
'id="stopConfirmBtn" type="button" data-i18n="stop"'
|
|
6773
6870
|
]) {
|
|
6774
6871
|
if (!webUiTemplateSource.includes(expected)) fail(`web display regression guard missing: ${expected}`);
|
|
6775
6872
|
}
|
|
6873
|
+
// Execute the shipped renderer as well: a helper name alone does not prove
|
|
6874
|
+
// session identities remain visible or disclosure/action clicks stay local.
|
|
6875
|
+
run(process.execPath, ['--test', path.join(repoRoot, 'test', 'web-ui-session-display.test.mjs')]);
|
|
6776
6876
|
if (webUiTemplateSource.includes("p.status === 'running' ?")) {
|
|
6777
6877
|
fail('detected peer action rendering still depends on status === running instead of liveness');
|
|
6778
6878
|
}
|
|
@@ -9280,8 +9380,9 @@ async function syntaxAndHelp() {
|
|
|
9280
9380
|
};
|
|
9281
9381
|
const html = webUiTemplate.webIndexHtml({ nonce: 'regression-template-nonce' });
|
|
9282
9382
|
if (!html.includes('<!doctype html>') ||
|
|
9283
|
-
!html.includes('<div class="app">') ||
|
|
9284
|
-
!html.includes('<script src="/assets/xterm.js"></script>')
|
|
9383
|
+
!html.includes('<div class="app" data-view="terminal">') ||
|
|
9384
|
+
!html.includes('<script src="/assets/xterm.js"></script>') ||
|
|
9385
|
+
!html.includes('<script src="/assets/addon-fit.js"></script>')) {
|
|
9285
9386
|
fail('web UI template module did not render the expected shell HTML');
|
|
9286
9387
|
}
|
|
9287
9388
|
const jsonReq = Readable.from(['{"ok":true}']);
|
|
@@ -9529,12 +9630,15 @@ async function syntaxAndHelp() {
|
|
|
9529
9630
|
expectEqual(webRuntime.publicRuntimeUrl(ipv6WildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8788/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl ipv6 wildcard');
|
|
9530
9631
|
expectEqual(webRuntime.localRuntimeUrl(localRuntime, null), 'http://127.0.0.1:8789/?token=tok', 'localRuntimeUrl no project');
|
|
9531
9632
|
const mainHelp = run(process.execPath, [hccBin, '--help']);
|
|
9532
|
-
if (
|
|
9633
|
+
if (/^\s*setup(?:\s|$)/m.test(mainHelp) || mainHelp.includes('--web-managed')) {
|
|
9533
9634
|
fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
|
|
9534
9635
|
}
|
|
9535
9636
|
if (!mainHelp.includes(' update Update the global npm install of hello-cc')) {
|
|
9536
9637
|
fail(`main help missing update command:\n${mainHelp}`);
|
|
9537
9638
|
}
|
|
9639
|
+
if (!mainHelp.includes(' dsh <setup|status|web> Connect DeepSeek Harness through project-local hooks')) {
|
|
9640
|
+
fail(`main help missing dsh command:\n${mainHelp}`);
|
|
9641
|
+
}
|
|
9538
9642
|
if (!mainHelp.includes(' state [--peer ID] Show timeline and next coordination action')) {
|
|
9539
9643
|
fail(`main help missing state command:\n${mainHelp}`);
|
|
9540
9644
|
}
|
|
@@ -9726,6 +9830,10 @@ function uninstallWorkflow() {
|
|
|
9726
9830
|
|
|
9727
9831
|
async function processEvidenceWorkflow() {
|
|
9728
9832
|
log('process evidence: live, reused, legacy, dead, root mismatch');
|
|
9833
|
+
// These fixtures deliberately backdate peers beyond the production reaper's
|
|
9834
|
+
// grace window. Keep the runtime stopped until the external-adoption cases so
|
|
9835
|
+
// a 30-second scheduler tick cannot turn an evidence snapshot into exited.
|
|
9836
|
+
await stopRuntime();
|
|
9729
9837
|
|
|
9730
9838
|
const createOwnedTask = (peer, title) => {
|
|
9731
9839
|
const output = hcc(['task', 'create', '--from', 'human', '--to', peer, '--title', title]);
|
|
@@ -9831,6 +9939,8 @@ async function processEvidenceWorkflow() {
|
|
|
9831
9939
|
cleanupBindingPeers(rootPeer);
|
|
9832
9940
|
}
|
|
9833
9941
|
|
|
9942
|
+
startRuntime();
|
|
9943
|
+
await waitRuntime();
|
|
9834
9944
|
const externalDir = path.join(root, '.hello-cc', 'bufs');
|
|
9835
9945
|
const shortExternalId = `evidence-external-short-${testId}`;
|
|
9836
9946
|
const shortProducer = spawn(process.execPath, [
|
|
@@ -10711,17 +10821,18 @@ function manualGcRetentionContractWorkflow() {
|
|
|
10711
10821
|
}
|
|
10712
10822
|
}
|
|
10713
10823
|
|
|
10714
|
-
//
|
|
10824
|
+
// Intentional detach persists; unpaused orphans recover by name and dead peers
|
|
10825
|
+
// are reaped without treating terminal detachment as process death.
|
|
10715
10826
|
async function sessionRecoveryWorkflow() {
|
|
10716
10827
|
if (!tmuxAvailable()) {
|
|
10717
10828
|
log('session recovery skipped (tmux not installed)');
|
|
10718
10829
|
return;
|
|
10719
10830
|
}
|
|
10720
|
-
log('session recovery:
|
|
10831
|
+
log('session recovery: persistent detach + explicit resume + orphan adoption + dead peer reaping');
|
|
10721
10832
|
if (!fs.existsSync(path.join(root, '.hello-cc', 'runtime.json'))) startRuntime();
|
|
10722
10833
|
await waitRuntime();
|
|
10723
10834
|
|
|
10724
|
-
//
|
|
10835
|
+
// Default Stop pauses Web adoption while leaving the local terminal alive.
|
|
10725
10836
|
const readoptPeer = 'readopt-shell';
|
|
10726
10837
|
const pane = parsePane(hcc(['peer', 'start', readoptPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
10727
10838
|
const sessionName = tmuxManagedSession(root, readoptPeer);
|
|
@@ -10753,22 +10864,57 @@ async function sessionRecoveryWorkflow() {
|
|
|
10753
10864
|
detachedTakeover.status === 0) {
|
|
10754
10865
|
fail(`live detached tmux pane did not block takeover: ${JSON.stringify(detachedRow)}\n${detachedTakeover.stdout}\n${detachedTakeover.stderr}`);
|
|
10755
10866
|
}
|
|
10756
|
-
|
|
10867
|
+
const pauseEvent = eventPayloads('web.adoption.paused')
|
|
10868
|
+
.find((event) => event.payload?.peer === readoptPeer && event.payload?.pane === pane);
|
|
10869
|
+
if (!pauseEvent) fail('default detach did not persist its adoption pause');
|
|
10870
|
+
// A genuinely orphaned binding has no intentional pause. Simulate runtime
|
|
10871
|
+
// state loss while keeping the managed tmux name and process identity.
|
|
10872
|
+
const orphanPeer = 'unpaused-orphan-shell';
|
|
10873
|
+
const orphanPane = parsePane(hcc(['peer', 'start', orphanPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
10874
|
+
const orphanSessionName = tmuxManagedSession(root, orphanPeer);
|
|
10875
|
+
await stopRuntime();
|
|
10876
|
+
withMeshDb((db) => {
|
|
10877
|
+
db.prepare('UPDATE peer_bindings SET runtime_target = NULL WHERE peer = ?').run(orphanPeer);
|
|
10878
|
+
db.prepare("UPDATE peers SET status = 'detached' WHERE id = ?").run(orphanPeer);
|
|
10879
|
+
});
|
|
10880
|
+
startRuntime();
|
|
10881
|
+
await waitRuntime();
|
|
10757
10882
|
await waitFor(() => {
|
|
10758
|
-
const row = peerBindingRow(
|
|
10759
|
-
return Boolean(row && row.runtime_target ===
|
|
10760
|
-
}, 'orphan managed tmux session re-adoption', 20000);
|
|
10883
|
+
const row = peerBindingRow(orphanPeer);
|
|
10884
|
+
return Boolean(row && row.runtime_target === orphanPane);
|
|
10885
|
+
}, 'unpaused orphan managed tmux session re-adoption', 20000);
|
|
10886
|
+
// Cover another auto-attach cycle after restart: it must not override a pause.
|
|
10887
|
+
await sleep(5500);
|
|
10888
|
+
binding = peerBindingRow(readoptPeer);
|
|
10889
|
+
if (!binding || binding.runtime_target !== null ||
|
|
10890
|
+
runMaybe('tmux', ['has-session', '-t', sessionName]).status !== 0) {
|
|
10891
|
+
fail(`runtime restart or polling overrode an intentional live-terminal detach:\n${JSON.stringify(binding, null, 2)}`);
|
|
10892
|
+
}
|
|
10893
|
+
const pausedSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
10894
|
+
if ((pausedSessions.sessions || []).some((session) => session.id === readoptPeer && session.status === 'running')) {
|
|
10895
|
+
fail(`intentionally detached session was automatically re-adopted:\n${JSON.stringify(pausedSessions, null, 2)}`);
|
|
10896
|
+
}
|
|
10897
|
+
const resumeResponse = await runtimeFetch('/api/sessions/attach', {
|
|
10898
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
10899
|
+
body: JSON.stringify({ id: readoptPeer, kind: 'shell', pane, force: true })
|
|
10900
|
+
}, { root });
|
|
10901
|
+
const resumed = await resumeResponse.json();
|
|
10902
|
+
if (!resumeResponse.ok || resumed.session?.pane !== pane) {
|
|
10903
|
+
fail(`explicit attach did not resume the same detached terminal:\n${JSON.stringify(resumed, null, 2)}`);
|
|
10904
|
+
}
|
|
10761
10905
|
const restoredStatus = withMeshDb((db) => db.prepare('SELECT status FROM peers WHERE id = ?').get(readoptPeer)?.status);
|
|
10762
|
-
if (restoredStatus !== 'running') fail(`
|
|
10763
|
-
//
|
|
10906
|
+
if (restoredStatus !== 'running') fail(`explicitly resumed peer not marked running (status=${restoredStatus})`);
|
|
10907
|
+
// Both explicit resume and automatic orphan recovery expose a live session.
|
|
10764
10908
|
const sessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
10765
10909
|
if (!(sessions.sessions || []).some((s) => s.id === readoptPeer && s.status === 'running')) {
|
|
10766
|
-
fail(`
|
|
10910
|
+
fail(`explicitly resumed session not exposed by /api/sessions:\n${JSON.stringify(sessions, null, 2)}`);
|
|
10911
|
+
}
|
|
10912
|
+
if (!(sessions.sessions || []).some((session) => session.id === orphanPeer && session.pane === orphanPane && session.status === 'running')) {
|
|
10913
|
+
fail(`unpaused orphan session not exposed by /api/sessions:\n${JSON.stringify(sessions, null, 2)}`);
|
|
10767
10914
|
}
|
|
10768
10915
|
hcc(['task', 'takeover', '--peer', 'human', '--id', detachedTaskId, '--reason', 'detached evidence cleanup', '--force']);
|
|
10769
10916
|
hcc(['task', 'update', '--peer', 'human', '--id', detachedTaskId, '--status', 'abandoned', '--summary', 'detached evidence cleanup']);
|
|
10770
|
-
// Destroying the live session is the real teardown path
|
|
10771
|
-
// no kill flag; the web API's kill_tmux is the equivalent). Kill it directly
|
|
10917
|
+
// Destroying the live session is the real teardown path. Kill it directly
|
|
10772
10918
|
// so it does not leak into the final tmux-session leak assertion.
|
|
10773
10919
|
runMaybe('tmux', ['kill-session', '-t', sessionName]);
|
|
10774
10920
|
if (runMaybe('tmux', ['has-session', '-t', sessionName]).status === 0) {
|
|
@@ -10778,6 +10924,11 @@ async function sessionRecoveryWorkflow() {
|
|
|
10778
10924
|
db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(readoptPeer);
|
|
10779
10925
|
db.prepare('DELETE FROM peers WHERE id = ?').run(readoptPeer);
|
|
10780
10926
|
});
|
|
10927
|
+
runMaybe('tmux', ['kill-session', '-t', orphanSessionName]);
|
|
10928
|
+
withMeshDb((db) => {
|
|
10929
|
+
db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(orphanPeer);
|
|
10930
|
+
db.prepare('DELETE FROM peers WHERE id = ?').run(orphanPeer);
|
|
10931
|
+
});
|
|
10781
10932
|
|
|
10782
10933
|
// ── A peer whose process is gone must be reaped, not stuck at 'running' ──
|
|
10783
10934
|
const reaped = spawnSync('sleep', ['10'], { stdio: 'ignore' });
|