@logicseek/hello-cc 1.0.3 → 1.0.4
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 +29 -0
- package/docs/dsh.md +2 -2
- package/docs/dsh.zh-CN.md +2 -2
- package/lib/process/identity.mjs +36 -15
- package/lib/runtime/client.mjs +9 -1
- package/lib/web/runtime-main.mjs +4 -4
- package/lib/web/tmux-client-snapshot.mjs +85 -0
- package/package.json +1 -1
- package/scripts/regression.mjs +204 -46
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,35 @@ the release description from the current changelog section. Use
|
|
|
16
16
|
`npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
|
|
17
17
|
backfills.
|
|
18
18
|
|
|
19
|
+
## 1.0.4
|
|
20
|
+
|
|
21
|
+
### Summary
|
|
22
|
+
|
|
23
|
+
Session lists no longer run synchronous tmux client probes for each displayed
|
|
24
|
+
terminal. A bounded asynchronous snapshot supplies display information while
|
|
25
|
+
live process and terminal observations continue to guard control actions.
|
|
26
|
+
|
|
27
|
+
### Highlights
|
|
28
|
+
|
|
29
|
+
- Share one short-lived tmux display snapshot across sessions. Cold, stale or
|
|
30
|
+
failed observations remain unknown, and pending probes stop with the runtime.
|
|
31
|
+
- Preserve live checks for terminal control, rebind, stop and ownership.
|
|
32
|
+
- Treat exited macOS zombie processes as dead, so shutdown waits can complete
|
|
33
|
+
while a parent has yet to reap the PID; malformed observations remain unknown.
|
|
34
|
+
- Include the HTTP method, request path, deadline and elapsed time in runtime
|
|
35
|
+
request failures, without request bodies, query parameters or credentials.
|
|
36
|
+
- Keep DeepSeek Harness on the existing npm `latest` channel.
|
|
37
|
+
|
|
38
|
+
### Validation
|
|
39
|
+
|
|
40
|
+
Targeted tests cover slow probes, stale and failed snapshots, refresh coalescing,
|
|
41
|
+
shutdown and request diagnostics. Full regression, official installed Harness,
|
|
42
|
+
registry readback and current-Mac model checks have separate verification records.
|
|
43
|
+
The earlier 1.0.3 macOS CI timeout remains historical evidence: removing a known
|
|
44
|
+
blocking path does not identify the specific cause of that earlier timeout.
|
|
45
|
+
Regression shutdown tracks the exact fixture process identity, distinguishes
|
|
46
|
+
reused PIDs and zombies, and retains bounded failure diagnostics before cleanup.
|
|
47
|
+
|
|
19
48
|
## 1.0.3
|
|
20
49
|
|
|
21
50
|
### Summary
|
package/docs/dsh.md
CHANGED
|
@@ -113,7 +113,7 @@ Project overlays work directly from source. The managed manifest records the Cor
|
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
115
|
npm pack --ignore-scripts
|
|
116
|
-
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.
|
|
116
|
+
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.4.tgz
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
Harness requires explicit approval for native dependency builds. If add returns `ERR_PNPM_IGNORED_BUILDS`, merge the following into the selected profile's `pnpm-workspace.yaml` and rerun the same add command. The default path is `~/.dsh/profiles/web/pnpm-workspace.yaml`; use the configured DSH_HOME when different. Preserve existing settings and approve only the candidate's pinned native dependency:
|
|
@@ -123,7 +123,7 @@ allowBuilds:
|
|
|
123
123
|
node-pty@1.2.0-beta.15: true
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
Harness selects bundles through the profile's `dsh.profile.bundles`; do not also load the project Cordis/hooks overlay. Actual npm installation, official add/repeated add/remove, profile resolution and real-model collaboration calls have passed. The actual macOS desktop profile was installed and hot-loaded on 2026-10-02. Its paired ACP worker completed real collaboration calls using the existing DeepSeek route. The initially configured desktop sub2api model returned HTTP 403 because that account only accepts official Codex clients. After the desktop default was observed to have changed to official DeepSeek, a new session completed hcc_state, hcc_message_send and a real completed turn with that existing selection. The original sub2api route restriction remains.
|
|
126
|
+
Harness selects bundles through the profile's `dsh.profile.bundles`; do not also load the project Cordis/hooks overlay. Actual npm installation, official add/repeated add/remove, profile resolution and real-model collaboration calls have passed. The actual macOS desktop profile was installed and hot-loaded on 2026-10-02. Its paired ACP worker completed real collaboration calls using the existing DeepSeek route. The initially configured desktop sub2api model returned HTTP 403 because that account only accepts official Codex clients. After the desktop default was observed to have changed to official DeepSeek, a new session completed hcc_state, hcc_message_send and a real completed turn with that existing selection. The original sub2api route restriction remains. Maintenance releases use the normal npm latest channel. Harness manages profile package removal; project-overlay integration is disabled with `setup --mode off`.
|
|
127
127
|
|
|
128
128
|
See Cordis/native acceptance (source checkout: `docs/verification/2026-10-02-dsh-cordis-native.md`), bridge acceptance (source checkout: `docs/verification/2026-10-02-dsh-official-bridge.md`), and the integration plan (source checkout: `docs/plans/2026-10-02-dsh-integration.md`). Real-model checks used temporary projects and owned processes, without replacing existing user sessions. The isolated order fixture passed artifact/evidence/handoff/lock-release/task-completion checks. See the Mac device receipt (source checkout: `docs/verification/2026-10-02-dsh-device-install.md`). Publication, deployment, other-device installation and genuine business acceptance remain separate delivery stages.
|
|
129
129
|
|
package/docs/dsh.zh-CN.md
CHANGED
|
@@ -115,7 +115,7 @@ hcc native start --peer dsh-reviewer --provider dsh --binary /absolute/path/to/d
|
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
117
|
npm pack --ignore-scripts
|
|
118
|
-
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.
|
|
118
|
+
dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.4.tgz
|
|
119
119
|
```
|
|
120
120
|
|
|
121
121
|
Harness 的 pnpm 策略要求明确允许原生依赖构建。若首次 add 返回 `ERR_PNPM_IGNORED_BUILDS`,在所选 profile 的 `pnpm-workspace.yaml` 中合并以下配置,然后重跑同一 add 命令;默认位置是 `~/.dsh/profiles/web/pnpm-workspace.yaml`,自定义 DSH_HOME 时以该目录为准。保留文件中已有设置,只批准候选包使用的固定版本:
|
|
@@ -125,7 +125,7 @@ allowBuilds:
|
|
|
125
125
|
node-pty@1.2.0-beta.15: true
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
安装后由 Harness profile 的 `dsh.profile.bundles` 选择包;不要再同时加载项目 Cordis/hooks overlay。bundle 已通过实际 npm 安装、官方 add/重复 add/remove、隔离 profile 加载和真实模型工具调用。2026-10-02 已安装到本机真实 desktop profile 并热加载,配套 ACP 使用既有 DeepSeek 路由完成真实工具调用;初次桌面 sub2api 模型返回账户仅允许 Codex 官方客户端的 403;随后桌面默认模型已变为官方 DeepSeek,沿用当时配置的新建会话完成 hcc_state、hcc_message_send 和真实 completed 回合。原 sub2api
|
|
128
|
+
安装后由 Harness profile 的 `dsh.profile.bundles` 选择包;不要再同时加载项目 Cordis/hooks overlay。bundle 已通过实际 npm 安装、官方 add/重复 add/remove、隔离 profile 加载和真实模型工具调用。2026-10-02 已安装到本机真实 desktop profile 并热加载,配套 ACP 使用既有 DeepSeek 路由完成真实工具调用;初次桌面 sub2api 模型返回账户仅允许 Codex 官方客户端的 403;随后桌面默认模型已变为官方 DeepSeek,沿用当时配置的新建会话完成 hcc_state、hcc_message_send 和真实 completed 回合。原 sub2api 路由限制仍存在。后续维护版本沿用 npm latest 标签发布。profile 插件安装/移除由 Harness 管理,项目 overlay 则使用 `setup --mode off` 关闭。
|
|
129
129
|
|
|
130
130
|
验证记录见 Cordis/native 验收 (源码目录: `docs/verification/2026-10-02-dsh-cordis-native.md`)、兼容桥记录 (源码目录: `docs/verification/2026-10-02-dsh-official-bridge.md`) 和详细接入计划 (源码目录: `docs/plans/2026-10-02-dsh-integration.md`)。真实模型验收在临时项目和自有进程中完成;既有会话与模型凭据保留;本机实际安装记录见 Mac 接入验收 (源码目录: `docs/verification/2026-10-02-dsh-device-install.md`)。Linux 容器已验证,实际 WSL/员工设备和真实业务签收尚未完成;隔离订单样例已完成报告、证据、交接、解锁与任务结束验证。
|
|
131
131
|
|
package/lib/process/identity.mjs
CHANGED
|
@@ -97,35 +97,52 @@ function parseMacBootToken(text) {
|
|
|
97
97
|
return `${seconds}:${microseconds}`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function
|
|
100
|
+
function parseMacStateStart(text) {
|
|
101
|
+
const match = /^(\S+)\s+([^\r\n]+)$/.exec(String(text ?? '').trim());
|
|
102
|
+
// Darwin ps uses X only as a traced/debugged modifier; Z is its exit state.
|
|
103
|
+
if (!match || !/^[IRSTUZ][+<>AELNSsVWX]*$/.test(match[1])) return null;
|
|
104
|
+
return { state: match[1][0], startToken: match[2].trim() };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function macObservation(pid) {
|
|
108
|
+
const unknown = { state: 'unknown', identity: null };
|
|
101
109
|
let boot = run('sysctl', ['-n', 'kern.boottime']);
|
|
102
110
|
// Launchers often omit /usr/sbin from PATH; macOS still provides sysctl here.
|
|
103
111
|
if (boot.error?.code === 'ENOENT') boot = run('/usr/sbin/sysctl', ['-n', 'kern.boottime']);
|
|
104
|
-
if (boot.error || boot.status !== 0) return
|
|
112
|
+
if (boot.error || boot.status !== 0) return unknown;
|
|
105
113
|
const bootToken = parseMacBootToken(boot.stdout);
|
|
106
|
-
if (!bootToken) return
|
|
114
|
+
if (!bootToken) return unknown;
|
|
107
115
|
|
|
108
116
|
const psEnvironment = { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' };
|
|
109
|
-
|
|
117
|
+
// kill(pid, 0) also succeeds for an unreaped zombie on Darwin. Read state
|
|
118
|
+
// alongside each existing start-time probe, without adding subprocesses.
|
|
119
|
+
const started = run('ps', ['-p', String(pid), '-o', 'stat=', '-o', 'lstart='], psEnvironment);
|
|
120
|
+
const first = parseMacStateStart(started.stdout);
|
|
121
|
+
if (started.error || started.status !== 0 || !first) return unknown;
|
|
122
|
+
if (first.state === 'Z') return { state: 'dead', identity: null };
|
|
110
123
|
const command = run('ps', ['-p', String(pid), '-o', 'command='], psEnvironment);
|
|
111
|
-
const confirmedStarted = run('ps', ['-p', String(pid), '-o', 'lstart='], psEnvironment);
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
124
|
+
const confirmedStarted = run('ps', ['-p', String(pid), '-o', 'stat=', '-o', 'lstart='], psEnvironment);
|
|
125
|
+
const last = parseMacStateStart(confirmedStarted.stdout);
|
|
126
|
+
if (confirmedStarted.error || confirmedStarted.status !== 0 || !last) return unknown;
|
|
127
|
+
if (last.state === 'Z') return { state: 'dead', identity: null };
|
|
128
|
+
if (command.error || command.status !== 0) return unknown;
|
|
115
129
|
|
|
116
130
|
const commandOutput = String(command.stdout ?? '').trim();
|
|
117
131
|
const parsed = parsePsStartIdentity(
|
|
118
|
-
`${
|
|
132
|
+
`${first.startToken}\t${commandOutput}\n`
|
|
119
133
|
);
|
|
120
134
|
const confirmed = parsePsStartIdentity(
|
|
121
|
-
`${
|
|
135
|
+
`${last.startToken}\t${commandOutput}\n`
|
|
122
136
|
);
|
|
123
|
-
if (!parsed || !confirmed || confirmed.startToken !== parsed.startToken) return
|
|
137
|
+
if (!parsed || !confirmed || confirmed.startToken !== parsed.startToken) return unknown;
|
|
124
138
|
|
|
125
139
|
return {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
140
|
+
state: 'live',
|
|
141
|
+
identity: {
|
|
142
|
+
pid,
|
|
143
|
+
startToken: `${bootToken}:${parsed.startToken}`,
|
|
144
|
+
commandHash: hashCommand(parsed.command)
|
|
145
|
+
}
|
|
129
146
|
};
|
|
130
147
|
}
|
|
131
148
|
|
|
@@ -204,7 +221,11 @@ export function inspectProcessIdentity(value) {
|
|
|
204
221
|
if (observed.state === 'dead') return observed;
|
|
205
222
|
identity = observed.identity;
|
|
206
223
|
}
|
|
207
|
-
else if (process.platform === 'darwin')
|
|
224
|
+
else if (process.platform === 'darwin') {
|
|
225
|
+
const observed = macObservation(pid);
|
|
226
|
+
if (observed.state === 'dead') return observed;
|
|
227
|
+
identity = observed.identity;
|
|
228
|
+
}
|
|
208
229
|
} catch {
|
|
209
230
|
identity = null;
|
|
210
231
|
}
|
package/lib/runtime/client.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { CliError } from '../shared/errors.mjs';
|
|
|
2
2
|
import { readRuntime } from './state.mjs';
|
|
3
3
|
import { runtimeApiUrl, runtimeHttpRequest } from '../web/runtime.mjs';
|
|
4
4
|
import { withRuntimeApiVersionHeader } from '../web/api-version.mjs';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_CLI_NAME = 'hcc';
|
|
7
8
|
export const RUNTIME_SESSION_START_TIMEOUT_MS = 60_000;
|
|
@@ -56,7 +57,9 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
|
|
|
56
57
|
let res;
|
|
57
58
|
let text;
|
|
58
59
|
const abortScope = requestAbortScope(opts.signal, opts.timeoutMs);
|
|
60
|
+
const startedAt = performance.now();
|
|
59
61
|
try {
|
|
62
|
+
abortScope.signal.throwIfAborted();
|
|
60
63
|
res = await waitForAbort(runtimeHttpRequest(rt, route, {
|
|
61
64
|
method,
|
|
62
65
|
headers,
|
|
@@ -68,7 +71,12 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
|
|
|
68
71
|
} catch (err) {
|
|
69
72
|
throw new CliError('RUNTIME_UNREACHABLE', `Runtime is not reachable at ${rt.base_url}. Start ${cliName} web again.`, {
|
|
70
73
|
runtime: rt.source || rt.base_url,
|
|
71
|
-
message: err.message
|
|
74
|
+
message: err.message,
|
|
75
|
+
method,
|
|
76
|
+
path: url.pathname,
|
|
77
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
78
|
+
timeoutMs: opts.timeoutMs === undefined ? 8000 : opts.timeoutMs,
|
|
79
|
+
errorName: err.name || 'Error'
|
|
72
80
|
});
|
|
73
81
|
} finally {
|
|
74
82
|
abortScope.cleanup();
|
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -123,6 +123,7 @@ import { createAutoAttach } from '../web/auto-attach.mjs';
|
|
|
123
123
|
import { createPtySessions } from '../web/pty-sessions.mjs';
|
|
124
124
|
import { createHttpRoutes } from '../web/http-routes.mjs';
|
|
125
125
|
import { createSessionSerialize } from '../web/session-serialize.mjs';
|
|
126
|
+
import { createTmuxClientSnapshot } from '../web/tmux-client-snapshot.mjs';
|
|
126
127
|
import { createTmuxStream } from '../web/tmux-stream.mjs';
|
|
127
128
|
import {
|
|
128
129
|
ensureTmuxAvailable,
|
|
@@ -231,6 +232,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
231
232
|
const { WebSocketServer } = await import('ws');
|
|
232
233
|
const pty = ptyModule.default || ptyModule;
|
|
233
234
|
const sessions = new Map();
|
|
235
|
+
const tmuxClientSnapshot = createTmuxClientSnapshot();
|
|
234
236
|
|
|
235
237
|
const {
|
|
236
238
|
sessionKey, sessionsForProject,
|
|
@@ -238,10 +240,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
238
240
|
serializeBindingSummary, serializeSession,
|
|
239
241
|
broadcast, hasOpenClients, closeSessionClients: closeSerializedSessionClients
|
|
240
242
|
} = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath,
|
|
241
|
-
localClientObservation:
|
|
242
|
-
try { return tmuxClientObservation(tmuxSessionNameForPane(session.pane)); }
|
|
243
|
-
catch { return { state: 'unknown', count: null }; }
|
|
244
|
-
}
|
|
243
|
+
localClientObservation: tmuxClientSnapshot.observe
|
|
245
244
|
});
|
|
246
245
|
const controlLease = createControlLease({ broadcast: (session) => {
|
|
247
246
|
for (const [actionToken, socket] of session.actionTokenSockets || []) {
|
|
@@ -638,6 +637,7 @@ const {
|
|
|
638
637
|
let cleanupPromise = null;
|
|
639
638
|
let shutdownExitCode = 0;
|
|
640
639
|
async function performRuntimeCleanup() {
|
|
640
|
+
tmuxClientSnapshot.close();
|
|
641
641
|
clearRuntime(ctx);
|
|
642
642
|
// rob-07: clear this runtime's per-project pointers for every registered
|
|
643
643
|
// project, not just the primary ctx, so sibling projects don't keep
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
|
|
4
|
+
const UNKNOWN = Object.freeze({ state: 'unknown', count: null });
|
|
5
|
+
const MAX_AGE_MS = 5_000;
|
|
6
|
+
const REFRESH_AFTER_MS = 2_000;
|
|
7
|
+
|
|
8
|
+
function parseSnapshot(stdout) {
|
|
9
|
+
const rows = new Map();
|
|
10
|
+
for (const line of String(stdout).split(/\r?\n/).filter(Boolean)) {
|
|
11
|
+
const match = /^(%\d+)\|(\$\d+)\|(\d+)$/.exec(line);
|
|
12
|
+
if (!match || !Number.isSafeInteger(Number(match[3]))) throw new Error('Invalid tmux client snapshot');
|
|
13
|
+
const [, pane, session, countText] = match;
|
|
14
|
+
const count = Number(countText);
|
|
15
|
+
const previous = rows.get(pane);
|
|
16
|
+
// Linked windows may repeat a pane within or across sessions. A display
|
|
17
|
+
// hint must not guess which session's local clients control that pane.
|
|
18
|
+
if (rows.has(pane) && (!previous || previous.session !== session || previous.count !== count)) {
|
|
19
|
+
rows.set(pane, null);
|
|
20
|
+
} else if (!rows.has(pane)) {
|
|
21
|
+
rows.set(pane, { session, count });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return rows;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Display-only observations. Destructive actions must continue to query live
|
|
28
|
+
// clients through strictTmuxClientObservation and verify their ownership.
|
|
29
|
+
// One asynchronous read covers every pane; serialization never waits for tmux.
|
|
30
|
+
export function createTmuxClientSnapshot({ run = execFile, now = () => performance.now() } = {}) {
|
|
31
|
+
let rows = new Map();
|
|
32
|
+
let observedAt = -Infinity;
|
|
33
|
+
let nextRefreshAt = -Infinity;
|
|
34
|
+
let pending = null;
|
|
35
|
+
let closed = false;
|
|
36
|
+
|
|
37
|
+
function refresh(at) {
|
|
38
|
+
if (closed || pending || at < nextRefreshAt) return;
|
|
39
|
+
const operation = { controller: new globalThis.AbortController() };
|
|
40
|
+
pending = operation;
|
|
41
|
+
nextRefreshAt = at + REFRESH_AFTER_MS;
|
|
42
|
+
const complete = (error, stdout) => {
|
|
43
|
+
if (pending !== operation) return;
|
|
44
|
+
pending = null;
|
|
45
|
+
if (closed || operation.controller.signal.aborted) return;
|
|
46
|
+
nextRefreshAt = now() + REFRESH_AFTER_MS;
|
|
47
|
+
try {
|
|
48
|
+
if (error) throw error;
|
|
49
|
+
rows = parseSnapshot(stdout);
|
|
50
|
+
observedAt = at;
|
|
51
|
+
} catch {
|
|
52
|
+
rows = new Map();
|
|
53
|
+
observedAt = -Infinity;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
run('tmux', ['list-panes', '-a', '-F', '#{pane_id}|#{session_id}|#{session_attached}'], {
|
|
58
|
+
encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024,
|
|
59
|
+
signal: operation.controller.signal
|
|
60
|
+
}, complete);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
complete(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function observe(session) {
|
|
67
|
+
if (closed || session?.type !== 'tmux' || !/^%\d+$/.test(session.pane || '')) return UNKNOWN;
|
|
68
|
+
const at = now();
|
|
69
|
+
refresh(at);
|
|
70
|
+
const row = rows.get(session.pane);
|
|
71
|
+
if (!row || at - observedAt >= MAX_AGE_MS) return UNKNOWN;
|
|
72
|
+
return { state: 'known', count: row.count };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function close() {
|
|
76
|
+
closed = true;
|
|
77
|
+
rows = new Map();
|
|
78
|
+
observedAt = -Infinity;
|
|
79
|
+
const operation = pending;
|
|
80
|
+
pending = null;
|
|
81
|
+
operation?.controller.abort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { observe, close };
|
|
85
|
+
}
|
package/package.json
CHANGED
package/scripts/regression.mjs
CHANGED
|
@@ -8,12 +8,14 @@ import path from 'node:path';
|
|
|
8
8
|
import process from 'node:process';
|
|
9
9
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
+
import { performance } from 'node:perf_hooks';
|
|
11
12
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
12
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
13
14
|
import { Readable } from 'node:stream';
|
|
14
15
|
import { pathToFileURL } from 'node:url';
|
|
15
16
|
import WebSocket from 'ws';
|
|
16
|
-
import { inspectProcessIdentity } from '../lib/process/identity.mjs';
|
|
17
|
+
import { inspectProcessIdentity, compareProcessIdentity } from '../lib/process/identity.mjs';
|
|
18
|
+
import { redactSecrets } from '../lib/shared/redact.mjs';
|
|
17
19
|
import { applyBufferPlan, planBufferFiles } from '../lib/runtime/buffer-gc.mjs';
|
|
18
20
|
|
|
19
21
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
@@ -29,14 +31,16 @@ const secondProjectRoot = path.join(root, 'second-project');
|
|
|
29
31
|
const TERMINAL_WEBSOCKET_TIMEOUT_MS = 15_000;
|
|
30
32
|
const realHome = process.env.HOME || os.homedir();
|
|
31
33
|
const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
|
|
32
|
-
|
|
33
|
-
encoding: 'utf8',
|
|
34
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
35
|
-
}).stdout.trim();
|
|
34
|
+
let realTmuxBin = '';
|
|
36
35
|
const port = 22000 + (process.pid % 10000);
|
|
37
36
|
|
|
38
37
|
let tmuxStarted = false;
|
|
39
38
|
let runtimePid = null;
|
|
39
|
+
let runtimeOwner = null;
|
|
40
|
+
let currentStage = 'startup';
|
|
41
|
+
let lastStopDiagnostic = null;
|
|
42
|
+
const diagnosticSecrets = new Set();
|
|
43
|
+
const fixtureFailures = new WeakSet();
|
|
40
44
|
const managedTmuxSessions = new Set();
|
|
41
45
|
|
|
42
46
|
const env = {
|
|
@@ -53,18 +57,153 @@ delete env.TMUX;
|
|
|
53
57
|
delete env.TMUX_PANE;
|
|
54
58
|
|
|
55
59
|
function log(message) {
|
|
60
|
+
currentStage = String(message);
|
|
56
61
|
process.stdout.write(`${message}\n`);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function fail(message) {
|
|
65
|
+
const error = fixtureFailureError(message);
|
|
60
66
|
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
61
|
-
const escaped =
|
|
67
|
+
const escaped = fixtureDiagnosticText({ stage: currentStage, failure: fixtureFailureDiagnostic(error) }, diagnosticSecrets)
|
|
62
68
|
.replace(/%/g, '%25')
|
|
63
69
|
.replace(/\r/g, '%0D')
|
|
64
70
|
.replace(/\n/g, '%0A');
|
|
65
71
|
process.stderr.write(`::error::${escaped}\n`);
|
|
66
72
|
}
|
|
67
|
-
throw
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function fixtureFailureError(message) {
|
|
77
|
+
const error = new Error(String(message));
|
|
78
|
+
Object.defineProperty(error, 'fixtureStack', {
|
|
79
|
+
value: Object.freeze(new Error('fixture failure').stack.split('\n').slice(1, 7))
|
|
80
|
+
});
|
|
81
|
+
fixtureFailures.add(error);
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fixtureFailureDiagnostic(error) {
|
|
86
|
+
const names = ['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AssertionError', 'AbortError'];
|
|
87
|
+
return {
|
|
88
|
+
name: names.includes(error?.name) ? error.name : 'Error',
|
|
89
|
+
stack: fixtureFailures.has(error)
|
|
90
|
+
? error.fixtureStack
|
|
91
|
+
: new Error('regression failure handler').stack.split('\n').slice(1, 7)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function fixtureDiagnosticText(value, secrets = []) {
|
|
96
|
+
function scrub(text) {
|
|
97
|
+
for (const secret of secrets) {
|
|
98
|
+
if (typeof secret === 'string' && secret.length >= 8) text = text.split(secret).join('[REDACTED]');
|
|
99
|
+
}
|
|
100
|
+
return redactSecrets(text)
|
|
101
|
+
.replace(/("(?:[^"\\]*token|authorization|cookie|[^"\\]*secret|password)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, '$1"[REDACTED]"');
|
|
102
|
+
}
|
|
103
|
+
function visit(entry) {
|
|
104
|
+
if (typeof entry === 'string') return scrub(entry);
|
|
105
|
+
if (Array.isArray(entry)) return entry.map(visit);
|
|
106
|
+
if (entry && typeof entry === 'object') return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, visit(item)]));
|
|
107
|
+
return entry;
|
|
108
|
+
}
|
|
109
|
+
return typeof value === 'string' ? scrub(value) : JSON.stringify(visit(redactSecrets(value)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fixturePsState(pid) {
|
|
113
|
+
const result = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], {
|
|
114
|
+
encoding: 'utf8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore']
|
|
115
|
+
});
|
|
116
|
+
const state = String(result.stdout || '').trim();
|
|
117
|
+
return result.status === 0 && /^[A-Za-z+<>NslLWE-]{1,16}$/.test(state) ? state : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function observeFixtureOwner(owner, { inspect = inspectProcessIdentity, psState = fixturePsState } = {}) {
|
|
121
|
+
if (compareProcessIdentity(owner, owner) !== 'live') return { state: 'unknown', reason: 'missing_owner_identity' };
|
|
122
|
+
const observed = inspect(owner.pid);
|
|
123
|
+
const state = psState(owner.pid);
|
|
124
|
+
const details = { pid: owner.pid, psState: state, observedState: observed.state };
|
|
125
|
+
if (observed.state === 'dead') return { ...details, state: 'exited', reason: 'process_dead' };
|
|
126
|
+
if (/^[ZX]/i.test(state || '')) return { ...details, state: 'exited', reason: 'zombie_or_dead' };
|
|
127
|
+
if (observed.state !== 'live' || compareProcessIdentity(observed.identity, observed.identity) !== 'live') {
|
|
128
|
+
return { ...details, state: 'unknown', reason: 'identity_unavailable' };
|
|
129
|
+
}
|
|
130
|
+
details.birthHash = crypto.createHash('sha256').update(observed.identity.startToken).digest('hex');
|
|
131
|
+
details.commandHash = observed.identity.commandHash;
|
|
132
|
+
if (compareProcessIdentity(owner, observed.identity) === 'dead') return { ...details, state: 'exited', reason: 'pid_reused' };
|
|
133
|
+
if (owner.commandHash !== observed.identity.commandHash) return { ...details, state: 'unknown', reason: 'command_changed' };
|
|
134
|
+
return { ...details, state: 'same-owner', reason: 'identity_match' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function signalFixtureOwner(owner, signal, { observe = observeFixtureOwner, send = process.kill.bind(process) } = {}) {
|
|
138
|
+
const observation = observe(owner);
|
|
139
|
+
if (observation.state !== 'same-owner') return { signal, sent: false, observation };
|
|
140
|
+
try { send(owner.pid, signal); return { signal, sent: true, observation }; }
|
|
141
|
+
catch (error) { return { signal, sent: false, observation, code: error.code || 'SIGNAL_FAILED' }; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function waitForFixtureOwnerExit(owner, timeoutMs, {
|
|
145
|
+
observe = observeFixtureOwner, monotonicNow = () => performance.now(), wait = sleep
|
|
146
|
+
} = {}) {
|
|
147
|
+
const deadline = monotonicNow() + timeoutMs;
|
|
148
|
+
for (;;) {
|
|
149
|
+
const observation = observe(owner);
|
|
150
|
+
if (observation.state === 'exited') return observation;
|
|
151
|
+
const remaining = deadline - monotonicNow();
|
|
152
|
+
if (remaining <= 0) {
|
|
153
|
+
const error = new Error('runtime fixture owner did not exit before deadline');
|
|
154
|
+
error.observation = observation;
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
await wait(Math.min(100, remaining));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function fixtureDownResult(result) {
|
|
162
|
+
let payload = null;
|
|
163
|
+
try { payload = JSON.parse(result.stdout || ''); } catch {}
|
|
164
|
+
const extra = payload?.error?.extra || payload?.error || {};
|
|
165
|
+
const code = payload?.error?.code || result.error?.code;
|
|
166
|
+
const numeric = ['pid', 'elapsedMs', 'timeoutMs'];
|
|
167
|
+
return {
|
|
168
|
+
status: Number.isSafeInteger(result.status) ? result.status : null,
|
|
169
|
+
signal: /^SIG[A-Z0-9]{1,12}$/.test(result.signal || '') ? result.signal : null,
|
|
170
|
+
code: typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(code) ? code : null,
|
|
171
|
+
extra: Object.fromEntries(numeric.filter(key => typeof extra[key] === 'number' &&
|
|
172
|
+
Number.isFinite(extra[key]) && extra[key] >= 0).map(key => [key, extra[key]]))
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function fixtureStopEnvironment(base) {
|
|
177
|
+
const local = { ...base, HCC_RUNTIME_LOCAL_ONLY: '1' };
|
|
178
|
+
delete local.HCC_RUNTIME_URL;
|
|
179
|
+
delete local.HCC_RUNTIME_TOKEN;
|
|
180
|
+
return local;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function rememberRuntimePid(pid) {
|
|
184
|
+
runtimePid = pid;
|
|
185
|
+
runtimeOwner = null;
|
|
186
|
+
try {
|
|
187
|
+
const pointer = currentRuntime();
|
|
188
|
+
if (pointer.token) {
|
|
189
|
+
diagnosticSecrets.add(pointer.token);
|
|
190
|
+
for (const part of pointer.token.split(/\s+/)) if (part.length >= 8) diagnosticSecrets.add(part);
|
|
191
|
+
}
|
|
192
|
+
if (pointer.process_identity?.pid === pid && compareProcessIdentity(pointer.process_identity, pointer.process_identity) === 'live') {
|
|
193
|
+
runtimeOwner = { ...pointer.process_identity };
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function collectRuntimeDiagnostic(extra = {}) {
|
|
199
|
+
const diagnostic = { stage: currentStage, pid: runtimePid,
|
|
200
|
+
owner: observeFixtureOwner(runtimeOwner), pointerExists: fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), ...extra };
|
|
201
|
+
try {
|
|
202
|
+
const db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { readOnly: true, timeout: 200 });
|
|
203
|
+
try { diagnostic.events = db.prepare('SELECT id, type FROM events ORDER BY id DESC LIMIT 12').all(); }
|
|
204
|
+
finally { db.close(); }
|
|
205
|
+
} catch {}
|
|
206
|
+
return fixtureDiagnosticText(diagnostic, diagnosticSecrets);
|
|
68
207
|
}
|
|
69
208
|
|
|
70
209
|
function commandText(command, args) {
|
|
@@ -1587,15 +1726,7 @@ function assertHtmlCsp(response, html, label) {
|
|
|
1587
1726
|
}
|
|
1588
1727
|
|
|
1589
1728
|
function processIsAlive(pid) {
|
|
1590
|
-
|
|
1591
|
-
process.kill(pid, 0);
|
|
1592
|
-
// Linux keeps an exited child addressable until PID 1 reaps its zombie.
|
|
1593
|
-
// Product liveness treats that state as dead, and the regression wait must
|
|
1594
|
-
// use the same semantic boundary instead of waiting forever on kill(0).
|
|
1595
|
-
return process.platform !== 'linux' || inspectProcessIdentity(pid).state !== 'dead';
|
|
1596
|
-
} catch {
|
|
1597
|
-
return false;
|
|
1598
|
-
}
|
|
1729
|
+
return inspectProcessIdentity(pid).state !== 'dead' && !/^[ZX]/i.test(fixturePsState(pid) || '');
|
|
1599
1730
|
}
|
|
1600
1731
|
|
|
1601
1732
|
async function waitForProcessExit(pid, label, timeoutMs = 5000) {
|
|
@@ -2546,7 +2677,7 @@ function startRuntime(options = {}) {
|
|
|
2546
2677
|
const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], { env: runtimeEnv });
|
|
2547
2678
|
const match = output.match(/^pid:\s*(\d+)/m);
|
|
2548
2679
|
if (!match) fail(`hcc web did not print background pid:\n${output}`);
|
|
2549
|
-
|
|
2680
|
+
rememberRuntimePid(Number.parseInt(match[1], 10));
|
|
2550
2681
|
if (!output.includes('web started in background')) fail(`hcc web did not report background start:\n${output}`);
|
|
2551
2682
|
}
|
|
2552
2683
|
|
|
@@ -2644,7 +2775,7 @@ async function webSecretRedactionWorkflow() {
|
|
|
2644
2775
|
]);
|
|
2645
2776
|
const match = output.match(/^pid:\s*(\d+)/m);
|
|
2646
2777
|
if (!match) fail(`redaction web did not print background pid`);
|
|
2647
|
-
|
|
2778
|
+
rememberRuntimePid(Number.parseInt(match[1], 10));
|
|
2648
2779
|
|
|
2649
2780
|
try {
|
|
2650
2781
|
await waitRuntime();
|
|
@@ -2738,6 +2869,7 @@ async function assertWebWrapperParentSurvives() {
|
|
|
2738
2869
|
const wrapperRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-wrapper-root-${testId}-`));
|
|
2739
2870
|
const wrapperPort = port + 101;
|
|
2740
2871
|
let wrapperPid = null;
|
|
2872
|
+
let wrapperOwner = null;
|
|
2741
2873
|
try {
|
|
2742
2874
|
const script = [
|
|
2743
2875
|
'set -e',
|
|
@@ -2752,14 +2884,15 @@ async function assertWebWrapperParentSurvives() {
|
|
|
2752
2884
|
if (!match) fail(`wrapper hcc web did not print background pid:\n${result.stdout}`);
|
|
2753
2885
|
wrapperPid = Number.parseInt(match[1], 10);
|
|
2754
2886
|
ensureFile(path.join(wrapperRoot, '.hello-cc', 'runtime.json'));
|
|
2887
|
+
wrapperOwner = JSON.parse(fs.readFileSync(path.join(wrapperRoot, '.hello-cc', 'runtime.json'), 'utf8')).process_identity;
|
|
2755
2888
|
} finally {
|
|
2756
|
-
runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env });
|
|
2889
|
+
runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env: fixtureStopEnvironment(env) });
|
|
2757
2890
|
if (wrapperPid) {
|
|
2758
2891
|
try {
|
|
2759
|
-
await
|
|
2892
|
+
await waitForFixtureOwnerExit(wrapperOwner, 5000);
|
|
2760
2893
|
} catch {
|
|
2761
|
-
|
|
2762
|
-
try { await
|
|
2894
|
+
signalFixtureOwner(wrapperOwner, 'SIGTERM');
|
|
2895
|
+
try { await waitForFixtureOwnerExit(wrapperOwner, 2000); } catch {}
|
|
2763
2896
|
}
|
|
2764
2897
|
}
|
|
2765
2898
|
try { fs.rmSync(wrapperRoot, { recursive: true, force: true }); } catch {}
|
|
@@ -2768,28 +2901,42 @@ async function assertWebWrapperParentSurvives() {
|
|
|
2768
2901
|
|
|
2769
2902
|
async function stopRuntime() {
|
|
2770
2903
|
if (!runtimePid) return;
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2904
|
+
const owner = runtimeOwner;
|
|
2905
|
+
const before = observeFixtureOwner(owner);
|
|
2906
|
+
const down = fixtureDownResult(stopFixtureCli());
|
|
2907
|
+
lastStopDiagnostic = { before, down, stack: new Error('stopRuntime').stack.split('\n').slice(1, 7), signals: [] };
|
|
2773
2908
|
try {
|
|
2774
|
-
await
|
|
2909
|
+
await waitForFixtureOwnerExit(owner, 5000);
|
|
2775
2910
|
} catch (err) {
|
|
2776
|
-
|
|
2911
|
+
lastStopDiagnostic.atDeadline = collectRuntimeDiagnostic({ before, down });
|
|
2912
|
+
lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGTERM'));
|
|
2777
2913
|
try {
|
|
2778
|
-
await
|
|
2914
|
+
await waitForFixtureOwnerExit(owner, 2000);
|
|
2779
2915
|
} catch {
|
|
2780
|
-
|
|
2781
|
-
try { await
|
|
2782
|
-
throw err;
|
|
2916
|
+
lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGKILL'));
|
|
2917
|
+
try { await waitForFixtureOwnerExit(owner, 2000); } catch {}
|
|
2783
2918
|
}
|
|
2919
|
+
fail(`runtime stop failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
|
|
2784
2920
|
}
|
|
2921
|
+
if (down.status !== 0) fail(`runtime down command failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
|
|
2785
2922
|
runtimePid = null;
|
|
2923
|
+
runtimeOwner = null;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function stopFixtureCli() {
|
|
2927
|
+
try {
|
|
2928
|
+
const pointer = currentRuntime();
|
|
2929
|
+
if (compareProcessIdentity(runtimeOwner, pointer.process_identity) === 'live' &&
|
|
2930
|
+
runtimeOwner.commandHash === pointer.process_identity.commandHash) {
|
|
2931
|
+
return hccMaybe(['--json', 'down'], { env: fixtureStopEnvironment(env) });
|
|
2932
|
+
}
|
|
2933
|
+
} catch {}
|
|
2934
|
+
return { status: null, error: { code: 'FIXTURE_OWNER_POINTER_UNAVAILABLE' } };
|
|
2786
2935
|
}
|
|
2787
2936
|
|
|
2788
2937
|
function cleanup() {
|
|
2789
|
-
try {
|
|
2790
|
-
if (runtimePid)
|
|
2791
|
-
try { process.kill(runtimePid, 'SIGTERM'); } catch {}
|
|
2792
|
-
}
|
|
2938
|
+
try { stopFixtureCli(); } catch {}
|
|
2939
|
+
if (runtimePid) signalFixtureOwner(runtimeOwner, 'SIGTERM');
|
|
2793
2940
|
const managedPrefixes = [
|
|
2794
2941
|
tmuxManagedSessionPrefix(root),
|
|
2795
2942
|
tmuxManagedSessionPrefix(secondProjectRoot)
|
|
@@ -2877,7 +3024,7 @@ async function setupRegression() {
|
|
|
2877
3024
|
const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
2878
3025
|
const match = output.match(/^pid:\s*(\d+)/m);
|
|
2879
3026
|
if (!match) fail(`hcc web did not print background pid during bootstrap:\n${output}`);
|
|
2880
|
-
|
|
3027
|
+
rememberRuntimePid(Number.parseInt(match[1], 10));
|
|
2881
3028
|
await waitRuntime();
|
|
2882
3029
|
for (const file of liveBufferFiles) ensureFile(file);
|
|
2883
3030
|
for (const file of liveBufferFiles) fs.rmSync(file, { force: true });
|
|
@@ -2957,7 +3104,7 @@ async function setupRegression() {
|
|
|
2957
3104
|
const directTlsOutput = hcc(['web', '--local', '--tls', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
2958
3105
|
const directTlsMatch = directTlsOutput.match(/^pid:\s*(\d+)/m);
|
|
2959
3106
|
if (!directTlsMatch) fail(`direct TLS web did not print background pid:\n${directTlsOutput}`);
|
|
2960
|
-
|
|
3107
|
+
rememberRuntimePid(Number.parseInt(directTlsMatch[1], 10));
|
|
2961
3108
|
await waitFor(async () => {
|
|
2962
3109
|
const runtimeFile = path.join(root, '.hello-cc', 'runtime.json');
|
|
2963
3110
|
if (!fs.existsSync(runtimeFile)) return false;
|
|
@@ -3016,7 +3163,7 @@ async function setupRegression() {
|
|
|
3016
3163
|
const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
3017
3164
|
const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3018
3165
|
if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
|
|
3019
|
-
|
|
3166
|
+
rememberRuntimePid(Number.parseInt(tokenMatch[1], 10));
|
|
3020
3167
|
if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
|
|
3021
3168
|
fail(`default web output did not include remote token URL:\n${tokenOutput}`);
|
|
3022
3169
|
}
|
|
@@ -3200,7 +3347,7 @@ async function setupRegression() {
|
|
|
3200
3347
|
]);
|
|
3201
3348
|
const proxyPidMatch = proxyOutput.match(/^pid:\s*(\d+)/m);
|
|
3202
3349
|
if (!proxyPidMatch) fail(`trusted-proxy web did not print background pid:\n${proxyOutput}`);
|
|
3203
|
-
|
|
3350
|
+
rememberRuntimePid(Number.parseInt(proxyPidMatch[1], 10));
|
|
3204
3351
|
await waitRuntime();
|
|
3205
3352
|
const proxyRuntime = currentRuntime();
|
|
3206
3353
|
if (proxyRuntime.proxy_origin !== proxyOrigin || proxyRuntime.trust_proxy !== true) {
|
|
@@ -3286,7 +3433,7 @@ async function setupRegression() {
|
|
|
3286
3433
|
]);
|
|
3287
3434
|
const restoredPidMatch = restoredOutput.match(/^pid:\s*(\d+)/m);
|
|
3288
3435
|
if (!restoredPidMatch) fail(`post-proxy web did not print background pid:\n${restoredOutput}`);
|
|
3289
|
-
|
|
3436
|
+
rememberRuntimePid(Number.parseInt(restoredPidMatch[1], 10));
|
|
3290
3437
|
await waitRuntime();
|
|
3291
3438
|
|
|
3292
3439
|
const badJsonResponse = await runtimeFetch('/api/projects', {
|
|
@@ -3313,7 +3460,7 @@ async function setupRegression() {
|
|
|
3313
3460
|
const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
3314
3461
|
const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3315
3462
|
if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
|
|
3316
|
-
|
|
3463
|
+
rememberRuntimePid(Number.parseInt(stableTokenMatch[1], 10));
|
|
3317
3464
|
await waitRuntime();
|
|
3318
3465
|
const stableTokenRuntime = currentRuntime();
|
|
3319
3466
|
if (stableTokenRuntime.token === tokenRuntime.token) {
|
|
@@ -3327,7 +3474,7 @@ async function setupRegression() {
|
|
|
3327
3474
|
});
|
|
3328
3475
|
const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3329
3476
|
if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
|
|
3330
|
-
|
|
3477
|
+
rememberRuntimePid(Number.parseInt(fixedTokenMatch[1], 10));
|
|
3331
3478
|
await waitRuntime();
|
|
3332
3479
|
const fixedTokenRuntime = currentRuntime();
|
|
3333
3480
|
if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
|
|
@@ -3338,7 +3485,7 @@ async function setupRegression() {
|
|
|
3338
3485
|
const postFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
3339
3486
|
const postFixedMatch = postFixedOutput.match(/^pid:\s*(\d+)/m);
|
|
3340
3487
|
if (!postFixedMatch) fail(`post-fixed-token web did not print background pid:\n${postFixedOutput}`);
|
|
3341
|
-
|
|
3488
|
+
rememberRuntimePid(Number.parseInt(postFixedMatch[1], 10));
|
|
3342
3489
|
await waitRuntime();
|
|
3343
3490
|
const postFixedRuntime = currentRuntime();
|
|
3344
3491
|
if (postFixedRuntime.token === fixedToken || fs.existsSync(tokenFile)) {
|
|
@@ -3351,7 +3498,7 @@ async function setupRegression() {
|
|
|
3351
3498
|
const noTokenOutput = hcc(['web', '--local', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
|
|
3352
3499
|
const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
3353
3500
|
if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
|
|
3354
|
-
|
|
3501
|
+
rememberRuntimePid(Number.parseInt(noTokenMatch[1], 10));
|
|
3355
3502
|
if (noTokenOutput.includes('token=')) {
|
|
3356
3503
|
fail(`explicit no-token web output included token:\n${noTokenOutput}`);
|
|
3357
3504
|
}
|
|
@@ -11158,11 +11305,16 @@ function gcCoverageWorkflow() {
|
|
|
11158
11305
|
}
|
|
11159
11306
|
|
|
11160
11307
|
async function main() {
|
|
11308
|
+
realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
|
|
11309
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
11310
|
+
}).stdout.trim();
|
|
11161
11311
|
process.once('SIGINT', () => { cleanup(); process.exit(130); });
|
|
11162
11312
|
process.once('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
11163
11313
|
|
|
11164
11314
|
await setupRegression();
|
|
11315
|
+
currentStage = 'webSecretRedactionWorkflow';
|
|
11165
11316
|
await webSecretRedactionWorkflow();
|
|
11317
|
+
currentStage = 'cookieSessionExpiryWorkflow';
|
|
11166
11318
|
await cookieSessionExpiryWorkflow();
|
|
11167
11319
|
log('[2/13] runtime');
|
|
11168
11320
|
startRuntime();
|
|
@@ -11194,12 +11346,18 @@ async function main() {
|
|
|
11194
11346
|
log('FULL_REGRESSION_OK');
|
|
11195
11347
|
}
|
|
11196
11348
|
|
|
11197
|
-
main().catch((err) => {
|
|
11349
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) main().catch((err) => {
|
|
11350
|
+
const failure = fixtureFailureDiagnostic(err);
|
|
11351
|
+
const diagnostic = collectRuntimeDiagnostic({ failure, stop: lastStopDiagnostic });
|
|
11352
|
+
process.stderr.write(`[regression failure diagnostic] ${diagnostic}\n`);
|
|
11353
|
+
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
11354
|
+
process.stderr.write(`::error::${diagnostic.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}\n`);
|
|
11355
|
+
}
|
|
11198
11356
|
try { assertNoRealProjectRegistryLeak(); } catch (leakErr) {
|
|
11199
|
-
process.stderr.write(`${leakErr.stack || leakErr.message}\n`);
|
|
11357
|
+
process.stderr.write(`${fixtureDiagnosticText(leakErr.stack || leakErr.message, diagnosticSecrets)}\n`);
|
|
11200
11358
|
}
|
|
11201
11359
|
cleanup();
|
|
11202
|
-
process.stderr.write(`${err.stack || err.message}\n`);
|
|
11360
|
+
process.stderr.write(`${fixtureDiagnosticText(err.stack || err.message, diagnosticSecrets)}\n`);
|
|
11203
11361
|
process.exit(1);
|
|
11204
11362
|
}).finally(() => {
|
|
11205
11363
|
try {
|