@logicseek/hello-cc 1.0.1 → 1.0.2
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 +105 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -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/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 +25 -6
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- 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/native.mjs +130 -0
- 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 +3 -1
- package/lib/release/release-notes.mjs +9 -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/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -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 +2 -1
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +98 -6
- package/lib/web/session-serialize.mjs +0 -0
- 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 +11 -3
- 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 +243 -89
- 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/scripts/regression.mjs
CHANGED
|
@@ -1606,23 +1606,26 @@ async function expectWebSocketMarker(peer, marker) {
|
|
|
1606
1606
|
await new Promise((resolve, reject) => {
|
|
1607
1607
|
let sawSnapshot = false;
|
|
1608
1608
|
let sawMarker = false;
|
|
1609
|
-
const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
|
|
1609
|
+
const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
|
|
1610
1610
|
const timer = setTimeout(() => {
|
|
1611
1611
|
try { ws.terminate(); } catch {}
|
|
1612
1612
|
reject(new Error(`${peer} websocket timeout`));
|
|
1613
1613
|
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1614
|
-
ws.on('open', () => {
|
|
1615
|
-
const result = hccMaybe(['inject', peer, `echo ${marker}`]);
|
|
1616
|
-
if (result.status !== 0) reject(new Error(result.stderr || result.stdout || 'inject failed'));
|
|
1617
|
-
});
|
|
1618
1614
|
ws.on('message', (raw) => {
|
|
1619
1615
|
const msg = JSON.parse(String(raw));
|
|
1620
|
-
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
|
+
}
|
|
1621
1623
|
if (['snapshot', 'data', 'replace'].includes(msg.type) && String(msg.data || '').includes(marker)) {
|
|
1622
1624
|
sawMarker = true;
|
|
1623
1625
|
}
|
|
1624
1626
|
if (sawSnapshot && sawMarker) {
|
|
1625
1627
|
clearTimeout(timer);
|
|
1628
|
+
releaseTerminalControl(ws);
|
|
1626
1629
|
ws.close();
|
|
1627
1630
|
resolve();
|
|
1628
1631
|
}
|
|
@@ -1631,6 +1634,47 @@ async function expectWebSocketMarker(peer, marker) {
|
|
|
1631
1634
|
});
|
|
1632
1635
|
}
|
|
1633
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 }));
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
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
|
+
|
|
1634
1678
|
async function openTerminalWebSocket(peer) {
|
|
1635
1679
|
return new Promise((resolve, reject) => {
|
|
1636
1680
|
const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
|
|
@@ -1657,7 +1701,7 @@ async function openSessionActionChannel(peer, params = {}) {
|
|
|
1657
1701
|
for (const [key, value] of Object.entries(params)) {
|
|
1658
1702
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
1659
1703
|
}
|
|
1660
|
-
const ws = new WebSocket(url, runtimeWsOptions());
|
|
1704
|
+
const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
|
|
1661
1705
|
const timer = setTimeout(() => {
|
|
1662
1706
|
try { ws.terminate(); } catch {}
|
|
1663
1707
|
reject(new Error(`${peer} action token fetch timeout`));
|
|
@@ -1666,7 +1710,7 @@ async function openSessionActionChannel(peer, params = {}) {
|
|
|
1666
1710
|
const msg = JSON.parse(String(raw));
|
|
1667
1711
|
if (msg.type === 'snapshot') {
|
|
1668
1712
|
clearTimeout(timer);
|
|
1669
|
-
resolve({ token: msg.action_token || '', ws });
|
|
1713
|
+
resolve({ token: msg.action_token || '', ws, get epoch() { return ws.hccControl?.epoch; } });
|
|
1670
1714
|
}
|
|
1671
1715
|
});
|
|
1672
1716
|
ws.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
@@ -1677,8 +1721,23 @@ async function fetchSessionActionToken(peer, params = {}) {
|
|
|
1677
1721
|
return (await openSessionActionChannel(peer, params)).token;
|
|
1678
1722
|
}
|
|
1679
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
|
+
|
|
1680
1738
|
async function closeTerminalWebSocket(ws) {
|
|
1681
1739
|
if (!ws || ws.readyState === WebSocket.CLOSED) return;
|
|
1740
|
+
releaseTerminalControl(ws);
|
|
1682
1741
|
await new Promise((resolve) => {
|
|
1683
1742
|
const timer = setTimeout(() => {
|
|
1684
1743
|
try { ws.terminate(); } catch {}
|
|
@@ -1701,7 +1760,7 @@ async function fetchTerminalSnapshot(peer, params = {}) {
|
|
|
1701
1760
|
for (const [key, value] of Object.entries(params)) {
|
|
1702
1761
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
1703
1762
|
}
|
|
1704
|
-
const ws = new WebSocket(url, runtimeWsOptions());
|
|
1763
|
+
const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
|
|
1705
1764
|
const timer = setTimeout(() => {
|
|
1706
1765
|
try { ws.terminate(); } catch {}
|
|
1707
1766
|
reject(new Error(`${peer} terminal snapshot timeout`));
|
|
@@ -1710,6 +1769,7 @@ async function fetchTerminalSnapshot(peer, params = {}) {
|
|
|
1710
1769
|
const msg = JSON.parse(String(raw));
|
|
1711
1770
|
if (msg.type !== 'snapshot') return;
|
|
1712
1771
|
clearTimeout(timer);
|
|
1772
|
+
releaseTerminalControl(ws);
|
|
1713
1773
|
try { ws.close(); } catch {}
|
|
1714
1774
|
resolve(String(msg.data || ''));
|
|
1715
1775
|
});
|
|
@@ -1796,12 +1856,12 @@ async function openCookieTerminalWebSocket(peer, sid, params = {}) {
|
|
|
1796
1856
|
}
|
|
1797
1857
|
return new Promise((resolve, reject) => {
|
|
1798
1858
|
let settled = false;
|
|
1799
|
-
const ws = new WebSocket(url, {
|
|
1859
|
+
const ws = trackTerminalControl(new WebSocket(url, {
|
|
1800
1860
|
headers: {
|
|
1801
1861
|
Cookie: `hcc_sid=${sid}`,
|
|
1802
1862
|
Origin: new URL(baseUrl).origin
|
|
1803
1863
|
}
|
|
1804
|
-
});
|
|
1864
|
+
}));
|
|
1805
1865
|
const timer = setTimeout(() => {
|
|
1806
1866
|
if (settled) return;
|
|
1807
1867
|
settled = true;
|
|
@@ -1922,23 +1982,25 @@ async function assertEvictionClosesCookieWebSocket(peer, params = {}) {
|
|
|
1922
1982
|
async function expectResizeReplaceSnapshot(peer, marker) {
|
|
1923
1983
|
await new Promise((resolve, reject) => {
|
|
1924
1984
|
let sawSnapshot = false;
|
|
1925
|
-
const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
|
|
1985
|
+
const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
|
|
1926
1986
|
const timer = setTimeout(() => {
|
|
1927
1987
|
try { ws.terminate(); } catch {}
|
|
1928
1988
|
reject(new Error(`${peer} resize replace timeout`));
|
|
1929
1989
|
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1930
|
-
ws.on('open', () => {
|
|
1931
|
-
const result = hccMaybe(['inject', peer, `echo ${marker}`]);
|
|
1932
|
-
if (result.status !== 0) reject(new Error(result.stderr || result.stdout || 'inject failed'));
|
|
1933
|
-
});
|
|
1934
1990
|
ws.on('message', (raw) => {
|
|
1935
1991
|
const msg = JSON.parse(String(raw));
|
|
1936
1992
|
if (msg.type === 'snapshot') {
|
|
1937
1993
|
sawSnapshot = true;
|
|
1938
|
-
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);
|
|
1939
2000
|
}
|
|
1940
2001
|
if (sawSnapshot && msg.type === 'replace' && String(msg.data || '').includes(marker)) {
|
|
1941
2002
|
clearTimeout(timer);
|
|
2003
|
+
releaseTerminalControl(ws);
|
|
1942
2004
|
ws.close();
|
|
1943
2005
|
resolve();
|
|
1944
2006
|
}
|
|
@@ -1953,8 +2015,7 @@ async function expectWebSocketInputVisible(peer, marker) {
|
|
|
1953
2015
|
let sent = false;
|
|
1954
2016
|
let sawMarkerAfterInput = false;
|
|
1955
2017
|
let sawFrameAfterInput = false;
|
|
1956
|
-
|
|
1957
|
-
const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
|
|
2018
|
+
const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
|
|
1958
2019
|
const timer = setTimeout(() => {
|
|
1959
2020
|
try { ws.terminate(); } catch {}
|
|
1960
2021
|
reject(new Error(`${peer} websocket input visibility timeout`));
|
|
@@ -1964,10 +2025,11 @@ async function expectWebSocketInputVisible(peer, marker) {
|
|
|
1964
2025
|
const data = String(msg.data || '');
|
|
1965
2026
|
if (msg.type === 'snapshot') {
|
|
1966
2027
|
sawSnapshot = true;
|
|
1967
|
-
if (msg.action_token) actionToken = msg.action_token;
|
|
1968
2028
|
if (!sent) {
|
|
1969
2029
|
sent = true;
|
|
1970
|
-
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);
|
|
1971
2033
|
}
|
|
1972
2034
|
return;
|
|
1973
2035
|
}
|
|
@@ -1977,6 +2039,7 @@ async function expectWebSocketInputVisible(peer, marker) {
|
|
|
1977
2039
|
}
|
|
1978
2040
|
if (sawSnapshot && sawFrameAfterInput && sawMarkerAfterInput) {
|
|
1979
2041
|
clearTimeout(timer);
|
|
2042
|
+
releaseTerminalControl(ws);
|
|
1980
2043
|
ws.close();
|
|
1981
2044
|
resolve();
|
|
1982
2045
|
}
|
|
@@ -1991,7 +2054,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
1991
2054
|
for (const [key, value] of Object.entries(params)) {
|
|
1992
2055
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
1993
2056
|
}
|
|
1994
|
-
const ws = new WebSocket(url, runtimeWsOptions());
|
|
2057
|
+
const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
|
|
1995
2058
|
const rejectedMarker = `REJECTED_${testId}_${Math.random().toString(16).slice(2)}`;
|
|
1996
2059
|
const acceptedMarker = `ACCEPTED_${testId}_${Math.random().toString(16).slice(2)}`;
|
|
1997
2060
|
let snapshotToken = '';
|
|
@@ -2008,6 +2071,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
2008
2071
|
if (settled) return;
|
|
2009
2072
|
settled = true;
|
|
2010
2073
|
clearTimeout(timer);
|
|
2074
|
+
releaseTerminalControl(ws);
|
|
2011
2075
|
try { ws.close(); } catch {}
|
|
2012
2076
|
if (err) reject(err);
|
|
2013
2077
|
else resolve();
|
|
@@ -2018,7 +2082,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
2018
2082
|
output += String(message.data || '');
|
|
2019
2083
|
if (message.type === 'snapshot' && !snapshotToken) {
|
|
2020
2084
|
snapshotToken = message.action_token || '';
|
|
2021
|
-
const frame = { type: 'input', data: `echo ${rejectedMarker}\r
|
|
2085
|
+
const frame = { type: 'input', data: `echo ${rejectedMarker}\r`, epoch: ws.hccControl.epoch };
|
|
2022
2086
|
if (suppliedToken !== undefined) frame.action_token = suppliedToken;
|
|
2023
2087
|
ws.send(JSON.stringify(frame));
|
|
2024
2088
|
setTimeout(() => {
|
|
@@ -2027,11 +2091,12 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
|
|
|
2027
2091
|
return;
|
|
2028
2092
|
}
|
|
2029
2093
|
checkedRejected = true;
|
|
2030
|
-
ws.send(JSON.stringify({
|
|
2094
|
+
claimTerminalControl(ws).then(() => ws.send(JSON.stringify({
|
|
2031
2095
|
type: 'input',
|
|
2032
2096
|
data: `echo ${acceptedMarker}\r`,
|
|
2033
|
-
action_token: snapshotToken
|
|
2034
|
-
|
|
2097
|
+
action_token: snapshotToken,
|
|
2098
|
+
epoch: ws.hccControl.epoch
|
|
2099
|
+
}))).catch(finish);
|
|
2035
2100
|
}, 350);
|
|
2036
2101
|
}
|
|
2037
2102
|
if (checkedRejected && output.includes(acceptedMarker)) finish();
|
|
@@ -2538,7 +2603,8 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2538
2603
|
ws.send(JSON.stringify({
|
|
2539
2604
|
type: 'input',
|
|
2540
2605
|
data: `echo ${marker}\r`,
|
|
2541
|
-
action_token: ws.hccActionToken
|
|
2606
|
+
action_token: ws.hccActionToken,
|
|
2607
|
+
epoch: ws.hccControl.epoch
|
|
2542
2608
|
}));
|
|
2543
2609
|
const closeResult = await closed;
|
|
2544
2610
|
if (closeResult.code !== 4001 || !closeResult.reason.includes('session expired')) {
|
|
@@ -2558,7 +2624,7 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2558
2624
|
try { ws.terminate(); } catch {}
|
|
2559
2625
|
}
|
|
2560
2626
|
try {
|
|
2561
|
-
await
|
|
2627
|
+
await stopControlledSession(expirySessionId);
|
|
2562
2628
|
} catch {}
|
|
2563
2629
|
await stopRuntime();
|
|
2564
2630
|
}
|
|
@@ -2607,7 +2673,8 @@ async function webSecretRedactionWorkflow() {
|
|
|
2607
2673
|
}, { root });
|
|
2608
2674
|
if (!create.ok) fail(`redaction PTY create failed with status ${create.status}`);
|
|
2609
2675
|
terminalWs = await openCookieTerminalWebSocket(sessionId, sid, { root });
|
|
2610
|
-
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 }));
|
|
2611
2678
|
|
|
2612
2679
|
const badProject = await fetch(new URL(`/api/projects?token=${encodeURIComponent(secret)}`, runtime.base_url), {
|
|
2613
2680
|
method: 'POST',
|
|
@@ -2652,7 +2719,7 @@ async function webSecretRedactionWorkflow() {
|
|
|
2652
2719
|
try { terminalWs.terminate(); } catch {}
|
|
2653
2720
|
}
|
|
2654
2721
|
try {
|
|
2655
|
-
await
|
|
2722
|
+
await stopControlledSession(sessionId);
|
|
2656
2723
|
} catch {}
|
|
2657
2724
|
await stopRuntime();
|
|
2658
2725
|
}
|
|
@@ -4369,17 +4436,16 @@ async function multiProjectWebWorkflow() {
|
|
|
4369
4436
|
}
|
|
4370
4437
|
for (const expected of [
|
|
4371
4438
|
'id="langSelect"',
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
"
|
|
4376
|
-
"
|
|
4377
|
-
"
|
|
4378
|
-
"
|
|
4379
|
-
"
|
|
4380
|
-
"
|
|
4381
|
-
"
|
|
4382
|
-
"'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":"活跃"',
|
|
4383
4449
|
"data-i18n=\"language\"",
|
|
4384
4450
|
"data-i18n-placeholder=\"projectPathPlaceholder\"",
|
|
4385
4451
|
"data-i18n-title=\"collapseSidebar\"",
|
|
@@ -4426,7 +4492,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4426
4492
|
'setPointerCapture',
|
|
4427
4493
|
'sideIsCollapsed(opposite) ? 0',
|
|
4428
4494
|
'Math.abs(delta) <= 3',
|
|
4429
|
-
"
|
|
4495
|
+
"hccUi.safeSet('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
|
|
4430
4496
|
'cursor: col-resize'
|
|
4431
4497
|
]) {
|
|
4432
4498
|
if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
|
|
@@ -4436,13 +4502,13 @@ async function multiProjectWebWorkflow() {
|
|
|
4436
4502
|
}
|
|
4437
4503
|
for (const expected of [
|
|
4438
4504
|
'function stateCardCollapsed(section)',
|
|
4439
|
-
"
|
|
4505
|
+
"hccUi.safeGet('hcc.stateCard.' + section + '.collapsed')",
|
|
4440
4506
|
'function stateCardHtml(section, title, count, bodyHtml)',
|
|
4441
4507
|
'state-card-toggle',
|
|
4442
4508
|
'aria-expanded=',
|
|
4443
4509
|
'state-card-collapsed',
|
|
4444
4510
|
'function bindStateCardToggles()',
|
|
4445
|
-
"
|
|
4511
|
+
"hccUi.safeSet('hcc.stateCard.' + section + '.collapsed'",
|
|
4446
4512
|
"stateCardHtml('automation'",
|
|
4447
4513
|
"stateCardHtml('timeline'",
|
|
4448
4514
|
"stateCardHtml('messages'",
|
|
@@ -4474,11 +4540,18 @@ async function multiProjectWebWorkflow() {
|
|
|
4474
4540
|
for (const expected of [
|
|
4475
4541
|
'term.onData((data) => {',
|
|
4476
4542
|
'sendTerminalInput(data);',
|
|
4477
|
-
|
|
4478
|
-
|
|
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.'
|
|
4479
4549
|
]) {
|
|
4480
4550
|
if (!html.includes(expected)) fail(`web terminal input forwarding missing: ${expected}`);
|
|
4481
4551
|
}
|
|
4552
|
+
if (html.includes('flushPendingTerminalInput') || html.includes('pendingTerminalInput')) {
|
|
4553
|
+
fail('web terminal recovery must not buffer or replay terminal input');
|
|
4554
|
+
}
|
|
4482
4555
|
if (!html.includes("stateCardHtml('timeline'") || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
|
|
4483
4556
|
fail('web state panel missing collaboration timeline or refresh routing');
|
|
4484
4557
|
}
|
|
@@ -4530,14 +4603,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4530
4603
|
return json.session;
|
|
4531
4604
|
};
|
|
4532
4605
|
const stopSession = async (id, payload = null, projectRoot = root) => {
|
|
4533
|
-
const
|
|
4534
|
-
? {
|
|
4535
|
-
method: 'POST',
|
|
4536
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4537
|
-
body: JSON.stringify(payload)
|
|
4538
|
-
}
|
|
4539
|
-
: { method: 'POST' };
|
|
4540
|
-
const response = await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, options, { root: projectRoot });
|
|
4606
|
+
const response = await stopControlledSession(id, payload || {}, projectRoot);
|
|
4541
4607
|
const json = await response.json();
|
|
4542
4608
|
if (!response.ok) fail(`web provider session stop failed: ${JSON.stringify(json)}`);
|
|
4543
4609
|
return json.session;
|
|
@@ -4685,7 +4751,8 @@ async function multiProjectWebWorkflow() {
|
|
|
4685
4751
|
const inputResponse = await cookieRuntimeFetch(inputRoute, cookieAdmin, {
|
|
4686
4752
|
method: 'POST',
|
|
4687
4753
|
headers: { 'Content-Type': 'application/json' },
|
|
4688
|
-
body: JSON.stringify({ data: `echo ${marker}\r
|
|
4754
|
+
body: JSON.stringify({ data: `echo ${marker}\r`, action_token: cookieAdminWs.hccActionToken,
|
|
4755
|
+
epoch: cookieAdminWs.hccControl.epoch })
|
|
4689
4756
|
}, { root });
|
|
4690
4757
|
if (!inputResponse.ok) fail(`same-origin cookie administrator input returned ${inputResponse.status}`);
|
|
4691
4758
|
});
|
|
@@ -4708,7 +4775,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4708
4775
|
const cookieStop = await cookieRuntimeFetch(stopRoute, cookieAdmin, {
|
|
4709
4776
|
method: 'POST',
|
|
4710
4777
|
headers: { 'Content-Type': 'application/json' },
|
|
4711
|
-
body:
|
|
4778
|
+
body: JSON.stringify({ action_token: cookieAdminWs.hccActionToken, epoch: cookieAdminWs.hccControl.epoch })
|
|
4712
4779
|
}, { root });
|
|
4713
4780
|
if (!cookieStop.ok) fail(`same-origin cookie administrator stop returned ${cookieStop.status}`);
|
|
4714
4781
|
} finally {
|
|
@@ -4805,14 +4872,16 @@ async function multiProjectWebWorkflow() {
|
|
|
4805
4872
|
kind: 'shell',
|
|
4806
4873
|
command: 'bash --noprofile --norc'
|
|
4807
4874
|
});
|
|
4808
|
-
const
|
|
4875
|
+
const encodedActionChannel = await openSessionActionChannel(encodedActionId, { root });
|
|
4876
|
+
const encodedActionToken = encodedActionChannel.token;
|
|
4809
4877
|
const siblingActionId = `web-action-sibling-${testId}`;
|
|
4810
4878
|
const siblingActionSession = await startProvider({
|
|
4811
4879
|
id: siblingActionId,
|
|
4812
4880
|
kind: 'shell',
|
|
4813
4881
|
command: 'bash --noprofile --norc'
|
|
4814
4882
|
}, otherRoot);
|
|
4815
|
-
const
|
|
4883
|
+
const siblingActionChannel = await openSessionActionChannel(siblingActionId, { root: otherRoot });
|
|
4884
|
+
const siblingActionToken = siblingActionChannel.token;
|
|
4816
4885
|
if (!encodedActionToken || !siblingActionToken) {
|
|
4817
4886
|
fail(`alternate sessions did not deliver action tokens:\n${JSON.stringify({ encodedActionSession, siblingActionSession }, null, 2)}`);
|
|
4818
4887
|
}
|
|
@@ -4845,10 +4914,12 @@ async function multiProjectWebWorkflow() {
|
|
|
4845
4914
|
}
|
|
4846
4915
|
await closeTerminalWebSocket(secondManagedChannel.ws);
|
|
4847
4916
|
await expectActionTokenRejected(secondManagedChannel.token, 'closed-connection');
|
|
4917
|
+
await claimTerminalControl(managedActionChannel.ws);
|
|
4918
|
+
await claimTerminalControl(encodedActionChannel.ws);
|
|
4848
4919
|
const encodedPeerAction = await runtimeFetch(`/api/peers/${encodeURIComponent(encodedActionId)}/actions/heartbeat`, {
|
|
4849
4920
|
method: 'POST',
|
|
4850
4921
|
headers: { 'Content-Type': 'application/json' },
|
|
4851
|
-
body: JSON.stringify({ action_token: encodedActionToken, renew_locks: false })
|
|
4922
|
+
body: JSON.stringify({ action_token: encodedActionToken, epoch: encodedActionChannel.epoch, renew_locks: false })
|
|
4852
4923
|
}, { root });
|
|
4853
4924
|
if (!encodedPeerAction.ok) {
|
|
4854
4925
|
fail(`URL-encoded peer action rejected its own token:\n${await encodedPeerAction.text()}`);
|
|
@@ -4857,11 +4928,13 @@ async function multiProjectWebWorkflow() {
|
|
|
4857
4928
|
const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
|
|
4858
4929
|
method: 'POST',
|
|
4859
4930
|
headers: { 'Content-Type': 'application/json' },
|
|
4860
|
-
body: JSON.stringify({ action_token: managedActionToken })
|
|
4931
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch })
|
|
4861
4932
|
}, { root })).json();
|
|
4862
4933
|
if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
|
|
4863
4934
|
fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
|
|
4864
4935
|
}
|
|
4936
|
+
await closeTerminalWebSocket(encodedActionChannel.ws);
|
|
4937
|
+
await closeTerminalWebSocket(siblingActionChannel.ws);
|
|
4865
4938
|
await stopSession(encodedActionSession.id);
|
|
4866
4939
|
await stopSession(siblingActionSession.id, null, otherRoot);
|
|
4867
4940
|
// Cookie-authenticated terminal sockets are tied to the opaque browser
|
|
@@ -4875,7 +4948,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4875
4948
|
method: 'POST',
|
|
4876
4949
|
headers: { 'Content-Type': 'application/json' },
|
|
4877
4950
|
body: JSON.stringify({
|
|
4878
|
-
action_token: managedActionToken,
|
|
4951
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
4879
4952
|
resource: webTtlResource,
|
|
4880
4953
|
task: Number(taskMatch[1]),
|
|
4881
4954
|
ttl: 60
|
|
@@ -4893,7 +4966,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4893
4966
|
method: 'POST',
|
|
4894
4967
|
headers: { 'Content-Type': 'application/json' },
|
|
4895
4968
|
body: JSON.stringify({
|
|
4896
|
-
action_token: managedActionToken,
|
|
4969
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
4897
4970
|
renew_locks: true,
|
|
4898
4971
|
...(ttlOverride === null ? {} : { ttl: ttlOverride })
|
|
4899
4972
|
})
|
|
@@ -4937,7 +5010,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4937
5010
|
const unknownHeartbeatResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/heartbeat`, {
|
|
4938
5011
|
method: 'POST',
|
|
4939
5012
|
headers: { 'Content-Type': 'application/json' },
|
|
4940
|
-
body: JSON.stringify({ action_token: managedActionToken, renew_locks: true })
|
|
5013
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, renew_locks: true })
|
|
4941
5014
|
}, { root });
|
|
4942
5015
|
const unknownHeartbeat = await unknownHeartbeatResponse.json();
|
|
4943
5016
|
if (!unknownHeartbeatResponse.ok || unknownHeartbeat.data?.renewed !== 0) {
|
|
@@ -4953,7 +5026,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4953
5026
|
const graceHeartbeatResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/heartbeat`, {
|
|
4954
5027
|
method: 'POST',
|
|
4955
5028
|
headers: { 'Content-Type': 'application/json' },
|
|
4956
|
-
body: JSON.stringify({ action_token: managedActionToken, renew_locks: true })
|
|
5029
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, renew_locks: true })
|
|
4957
5030
|
}, { root });
|
|
4958
5031
|
const graceHeartbeat = await graceHeartbeatResponse.json();
|
|
4959
5032
|
const graceHeartbeatState = withMeshDb((db) => ({
|
|
@@ -5033,7 +5106,7 @@ async function multiProjectWebWorkflow() {
|
|
|
5033
5106
|
method: 'POST',
|
|
5034
5107
|
headers: { 'Content-Type': 'application/json' },
|
|
5035
5108
|
body: JSON.stringify({
|
|
5036
|
-
action_token: managedActionToken,
|
|
5109
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
5037
5110
|
id: evidenceTaskId,
|
|
5038
5111
|
reason: 'owner process identity was reused',
|
|
5039
5112
|
policy: 'stale',
|
|
@@ -5094,7 +5167,7 @@ async function multiProjectWebWorkflow() {
|
|
|
5094
5167
|
const liveLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/lock-acquire`, {
|
|
5095
5168
|
method: 'POST',
|
|
5096
5169
|
headers: { 'Content-Type': 'application/json' },
|
|
5097
|
-
body: JSON.stringify({ action_token: managedActionToken, resource: liveExpiredResource, ttl: 60 })
|
|
5170
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, resource: liveExpiredResource, ttl: 60 })
|
|
5098
5171
|
}, { root });
|
|
5099
5172
|
const liveLockResult = await liveLockResponse.json();
|
|
5100
5173
|
if (liveLockResponse.ok || liveLockResult.error?.code !== 'LOCK_HELD') {
|
|
@@ -5122,7 +5195,7 @@ async function multiProjectWebWorkflow() {
|
|
|
5122
5195
|
const deadLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/lock-acquire`, {
|
|
5123
5196
|
method: 'POST',
|
|
5124
5197
|
headers: { 'Content-Type': 'application/json' },
|
|
5125
|
-
body: JSON.stringify({ action_token: managedActionToken, resource: deadLockResource, ttl: 60 })
|
|
5198
|
+
body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, resource: deadLockResource, ttl: 60 })
|
|
5126
5199
|
}, { root });
|
|
5127
5200
|
const deadLockResult = await deadLockResponse.json();
|
|
5128
5201
|
if (!deadLockResponse.ok || !deadLockResult.ok || deadLockResult.data?.lock?.owner !== managedActionPeer) {
|
|
@@ -5151,7 +5224,7 @@ async function multiProjectWebWorkflow() {
|
|
|
5151
5224
|
method: 'POST',
|
|
5152
5225
|
headers: { 'Content-Type': 'application/json' },
|
|
5153
5226
|
body: JSON.stringify({
|
|
5154
|
-
action_token: managedActionToken,
|
|
5227
|
+
action_token: managedActionToken, epoch: managedActionChannel.epoch,
|
|
5155
5228
|
resource: 'web/identity-spoof-lock',
|
|
5156
5229
|
task: Number(ownerTaskMatch[1])
|
|
5157
5230
|
})
|
|
@@ -6467,10 +6540,36 @@ function identityEnforcementWorkflow() {
|
|
|
6467
6540
|
db2.close();
|
|
6468
6541
|
}
|
|
6469
6542
|
|
|
6470
|
-
|
|
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 });
|
|
6471
6570
|
const db3 = new DatabaseSync(dbPath);
|
|
6472
6571
|
try {
|
|
6473
|
-
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');
|
|
6474
6573
|
const fakeRunner = db3.prepare('SELECT id FROM peers WHERE id = ?').get('fake-runner');
|
|
6475
6574
|
if (!realRunner || realRunner.status !== 'exited' || fakeRunner) {
|
|
6476
6575
|
fail(`run did not enforce system peer identity:\n${JSON.stringify({ realRunner, fakeRunner }, null, 2)}`);
|
|
@@ -6478,6 +6577,9 @@ function identityEnforcementWorkflow() {
|
|
|
6478
6577
|
} finally {
|
|
6479
6578
|
db3.close();
|
|
6480
6579
|
}
|
|
6580
|
+
if (JSON.stringify(readRunOwner()) !== JSON.stringify(ownerBeforeRun)) {
|
|
6581
|
+
fail('fresh run changed the occupied system peer owner');
|
|
6582
|
+
}
|
|
6481
6583
|
|
|
6482
6584
|
hccFrom(['peer', 'stop', 'fake-target'], identityRoot, { env: identityEnv });
|
|
6483
6585
|
const stopRequested = eventPayloads('peer.stop.requested', 5, dbPath)
|
|
@@ -6749,21 +6851,28 @@ async function syntaxAndHelp() {
|
|
|
6749
6851
|
'function detectedPeerCanStop(peer)',
|
|
6750
6852
|
"if (['exited', 'detached'].includes(status)) return false;",
|
|
6751
6853
|
'const canStop = detectedPeerCanStop(p);',
|
|
6752
|
-
|
|
6854
|
+
"${dshCoordinationPeer(p) ? '' : canStop ?",
|
|
6753
6855
|
'function providerSessionKnown(session)',
|
|
6754
6856
|
"tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'))",
|
|
6755
|
-
'
|
|
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]",
|
|
6756
6862
|
'const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));',
|
|
6757
6863
|
'const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));',
|
|
6758
|
-
"
|
|
6864
|
+
"hccUi.safeSet('hcc.showStaleDetected'",
|
|
6759
6865
|
'id="toggleStaleDetected"',
|
|
6760
|
-
"if (e.target.closest('[data-action]')) return;",
|
|
6866
|
+
"if (e.target.closest('details, [data-action]')) return;",
|
|
6761
6867
|
'id="stopKillLabel" data-i18n="dialog.killTmux"',
|
|
6762
6868
|
'id="stopCancelBtn" type="button" data-i18n="dialog.cancel"',
|
|
6763
6869
|
'id="stopConfirmBtn" type="button" data-i18n="stop"'
|
|
6764
6870
|
]) {
|
|
6765
6871
|
if (!webUiTemplateSource.includes(expected)) fail(`web display regression guard missing: ${expected}`);
|
|
6766
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')]);
|
|
6767
6876
|
if (webUiTemplateSource.includes("p.status === 'running' ?")) {
|
|
6768
6877
|
fail('detected peer action rendering still depends on status === running instead of liveness');
|
|
6769
6878
|
}
|
|
@@ -9271,8 +9380,9 @@ async function syntaxAndHelp() {
|
|
|
9271
9380
|
};
|
|
9272
9381
|
const html = webUiTemplate.webIndexHtml({ nonce: 'regression-template-nonce' });
|
|
9273
9382
|
if (!html.includes('<!doctype html>') ||
|
|
9274
|
-
!html.includes('<div class="app">') ||
|
|
9275
|
-
!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>')) {
|
|
9276
9386
|
fail('web UI template module did not render the expected shell HTML');
|
|
9277
9387
|
}
|
|
9278
9388
|
const jsonReq = Readable.from(['{"ok":true}']);
|
|
@@ -9520,12 +9630,15 @@ async function syntaxAndHelp() {
|
|
|
9520
9630
|
expectEqual(webRuntime.publicRuntimeUrl(ipv6WildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8788/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl ipv6 wildcard');
|
|
9521
9631
|
expectEqual(webRuntime.localRuntimeUrl(localRuntime, null), 'http://127.0.0.1:8789/?token=tok', 'localRuntimeUrl no project');
|
|
9522
9632
|
const mainHelp = run(process.execPath, [hccBin, '--help']);
|
|
9523
|
-
if (
|
|
9633
|
+
if (/^\s*setup(?:\s|$)/m.test(mainHelp) || mainHelp.includes('--web-managed')) {
|
|
9524
9634
|
fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
|
|
9525
9635
|
}
|
|
9526
9636
|
if (!mainHelp.includes(' update Update the global npm install of hello-cc')) {
|
|
9527
9637
|
fail(`main help missing update command:\n${mainHelp}`);
|
|
9528
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
|
+
}
|
|
9529
9642
|
if (!mainHelp.includes(' state [--peer ID] Show timeline and next coordination action')) {
|
|
9530
9643
|
fail(`main help missing state command:\n${mainHelp}`);
|
|
9531
9644
|
}
|
|
@@ -10708,17 +10821,18 @@ function manualGcRetentionContractWorkflow() {
|
|
|
10708
10821
|
}
|
|
10709
10822
|
}
|
|
10710
10823
|
|
|
10711
|
-
//
|
|
10824
|
+
// Intentional detach persists; unpaused orphans recover by name and dead peers
|
|
10825
|
+
// are reaped without treating terminal detachment as process death.
|
|
10712
10826
|
async function sessionRecoveryWorkflow() {
|
|
10713
10827
|
if (!tmuxAvailable()) {
|
|
10714
10828
|
log('session recovery skipped (tmux not installed)');
|
|
10715
10829
|
return;
|
|
10716
10830
|
}
|
|
10717
|
-
log('session recovery:
|
|
10831
|
+
log('session recovery: persistent detach + explicit resume + orphan adoption + dead peer reaping');
|
|
10718
10832
|
if (!fs.existsSync(path.join(root, '.hello-cc', 'runtime.json'))) startRuntime();
|
|
10719
10833
|
await waitRuntime();
|
|
10720
10834
|
|
|
10721
|
-
//
|
|
10835
|
+
// Default Stop pauses Web adoption while leaving the local terminal alive.
|
|
10722
10836
|
const readoptPeer = 'readopt-shell';
|
|
10723
10837
|
const pane = parsePane(hcc(['peer', 'start', readoptPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
10724
10838
|
const sessionName = tmuxManagedSession(root, readoptPeer);
|
|
@@ -10750,22 +10864,57 @@ async function sessionRecoveryWorkflow() {
|
|
|
10750
10864
|
detachedTakeover.status === 0) {
|
|
10751
10865
|
fail(`live detached tmux pane did not block takeover: ${JSON.stringify(detachedRow)}\n${detachedTakeover.stdout}\n${detachedTakeover.stderr}`);
|
|
10752
10866
|
}
|
|
10753
|
-
|
|
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();
|
|
10754
10882
|
await waitFor(() => {
|
|
10755
|
-
const row = peerBindingRow(
|
|
10756
|
-
return Boolean(row && row.runtime_target ===
|
|
10757
|
-
}, '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
|
+
}
|
|
10758
10905
|
const restoredStatus = withMeshDb((db) => db.prepare('SELECT status FROM peers WHERE id = ?').get(readoptPeer)?.status);
|
|
10759
|
-
if (restoredStatus !== 'running') fail(`
|
|
10760
|
-
//
|
|
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.
|
|
10761
10908
|
const sessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
10762
10909
|
if (!(sessions.sessions || []).some((s) => s.id === readoptPeer && s.status === 'running')) {
|
|
10763
|
-
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)}`);
|
|
10764
10914
|
}
|
|
10765
10915
|
hcc(['task', 'takeover', '--peer', 'human', '--id', detachedTaskId, '--reason', 'detached evidence cleanup', '--force']);
|
|
10766
10916
|
hcc(['task', 'update', '--peer', 'human', '--id', detachedTaskId, '--status', 'abandoned', '--summary', 'detached evidence cleanup']);
|
|
10767
|
-
// Destroying the live session is the real teardown path
|
|
10768
|
-
// 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
|
|
10769
10918
|
// so it does not leak into the final tmux-session leak assertion.
|
|
10770
10919
|
runMaybe('tmux', ['kill-session', '-t', sessionName]);
|
|
10771
10920
|
if (runMaybe('tmux', ['has-session', '-t', sessionName]).status === 0) {
|
|
@@ -10775,6 +10924,11 @@ async function sessionRecoveryWorkflow() {
|
|
|
10775
10924
|
db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(readoptPeer);
|
|
10776
10925
|
db.prepare('DELETE FROM peers WHERE id = ?').run(readoptPeer);
|
|
10777
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
|
+
});
|
|
10778
10932
|
|
|
10779
10933
|
// ── A peer whose process is gone must be reaped, not stuck at 'running' ──
|
|
10780
10934
|
const reaped = spawnSync('sleep', ['10'], { stdio: 'ignore' });
|