@logicseek/hello-cc 1.1.0-rc.1 → 1.1.0-rc.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.
Files changed (141) hide show
  1. package/CHANGELOG.md +229 -108
  2. package/README.md +1 -10
  3. package/README.zh-CN.md +1 -8
  4. package/docs/codex-account.zh-CN.md +38 -0
  5. package/docs/codex-protocol.md +59 -0
  6. package/docs/commands.md +13 -28
  7. package/docs/commands.zh-CN.md +11 -20
  8. package/docs/dsh.md +5 -3
  9. package/docs/dsh.zh-CN.md +6 -4
  10. package/docs/macos-process-identity.md +15 -0
  11. package/docs/native.md +176 -6
  12. package/docs/native.zh-CN.md +138 -3
  13. package/docs/web-handoff.zh-CN.md +107 -5
  14. package/lib/cli/app.mjs +9 -6
  15. package/lib/cli/commands/coordination.mjs +7 -2
  16. package/lib/cli/commands/gc.mjs +14 -12
  17. package/lib/cli/commands/hook.mjs +16 -3
  18. package/lib/cli/commands/install.mjs +37 -9
  19. package/lib/cli/commands/lock.mjs +3 -1
  20. package/lib/cli/commands/misc.mjs +105 -13
  21. package/lib/cli/commands/native.mjs +41 -48
  22. package/lib/cli/commands/peer.mjs +3 -0
  23. package/lib/cli/commands/run.mjs +13 -6
  24. package/lib/cli/commands/tmux.mjs +1 -2
  25. package/lib/cli-runtime.mjs +19 -2
  26. package/lib/core/coordination/gc-plan.mjs +5 -0
  27. package/lib/core/coordination/locks.mjs +3 -1
  28. package/lib/core/peers/evidence.mjs +21 -1
  29. package/lib/core/peers/tmux-safety.mjs +4 -0
  30. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  31. package/lib/core/sessions/launch.mjs +7 -1
  32. package/lib/db/connection.mjs +67 -21
  33. package/lib/diagnostics/codex-protocol.mjs +105 -0
  34. package/lib/guidance.mjs +20 -36
  35. package/lib/integrations/codex-account.mjs +143 -0
  36. package/lib/integrations/dsh-collaboration.mjs +3 -4
  37. package/lib/integrations/dsh.mjs +4 -2
  38. package/lib/integrations/native/claude.mjs +53 -9
  39. package/lib/integrations/native/codex.mjs +75 -15
  40. package/lib/integrations/native/dsh-acp.mjs +77 -24
  41. package/lib/integrations/native/index.mjs +4 -1
  42. package/lib/integrations/native/interactions.mjs +16 -3
  43. package/lib/integrations/native/jsonrpc.mjs +37 -5
  44. package/lib/integrations/native/telemetry.mjs +30 -0
  45. package/lib/process/identity.mjs +67 -28
  46. package/lib/process/pinned-cwd.mjs +302 -0
  47. package/lib/process/pinned-guidance.mjs +138 -0
  48. package/lib/process/pinned-project-state.mjs +105 -0
  49. package/lib/process/selected-cwd-identity.mjs +95 -0
  50. package/lib/runtime/buffer-evidence.mjs +7 -1
  51. package/lib/runtime/client.mjs +18 -1
  52. package/lib/runtime/native/client.mjs +12 -2
  53. package/lib/runtime/native/history.mjs +140 -0
  54. package/lib/runtime/native/launcher.mjs +106 -0
  55. package/lib/runtime/native/service.mjs +346 -52
  56. package/lib/runtime/native/store.mjs +75 -21
  57. package/lib/runtime/project-path.mjs +24 -5
  58. package/lib/runtime/projects.mjs +3 -0
  59. package/lib/runtime/state.mjs +61 -26
  60. package/lib/shared/file-lock.mjs +62 -17
  61. package/lib/shared/redact.mjs +2 -1
  62. package/lib/shared/socket-lock-worker.mjs +15 -6
  63. package/lib/web/agent-defaults.mjs +110 -0
  64. package/lib/web/auto-attach.mjs +3 -2
  65. package/lib/web/browser/contracts.d.ts +10 -0
  66. package/lib/web/browser/core.mjs +2005 -0
  67. package/lib/web/browser/project-data.mjs +158 -0
  68. package/lib/web/browser/session-sync.mjs +82 -0
  69. package/lib/web/browser-assets.mjs +19 -0
  70. package/lib/web/buffer-gc-runtime.mjs +2 -2
  71. package/lib/web/codex-app-server.mjs +104 -33
  72. package/lib/web/codex-sessions.mjs +60 -11
  73. package/lib/web/context-files.mjs +82 -0
  74. package/lib/web/cookie-auth.mjs +51 -3
  75. package/lib/web/external-sessions.mjs +22 -4
  76. package/lib/web/http-routes.mjs +237 -24
  77. package/lib/web/http.mjs +50 -4
  78. package/lib/web/liveness-reaper.mjs +2 -2
  79. package/lib/web/native-sessions.mjs +370 -21
  80. package/lib/web/project-contexts.mjs +235 -42
  81. package/lib/web/project-file-write.mjs +249 -0
  82. package/lib/web/project-files.mjs +268 -0
  83. package/lib/web/pty-sessions.mjs +11 -3
  84. package/lib/web/runtime-main.mjs +134 -82
  85. package/lib/web/session-serialize.mjs +87 -8
  86. package/lib/web/session-state-sync.mjs +98 -0
  87. package/lib/web/shutdown-diagnostics.mjs +38 -0
  88. package/lib/web/shutdown-response.mjs +21 -0
  89. package/lib/web/shutdown-state.mjs +91 -0
  90. package/lib/web/startup.mjs +76 -27
  91. package/lib/web/tmux-client-snapshot.mjs +85 -0
  92. package/lib/web/tmux-sessions.mjs +71 -7
  93. package/lib/web/tmux-stream.mjs +36 -8
  94. package/lib/web/ui-agent-defaults.mjs +149 -0
  95. package/lib/web/ui-agent-start.mjs +330 -0
  96. package/lib/web/ui-codex-account.mjs +81 -0
  97. package/lib/web/ui-codex.mjs +81 -73
  98. package/lib/web/ui-command-palette.mjs +21 -5
  99. package/lib/web/ui-file-writes.mjs +220 -0
  100. package/lib/web/ui-files.mjs +231 -0
  101. package/lib/web/ui-handoff.mjs +10 -2
  102. package/lib/web/ui-history.mjs +175 -105
  103. package/lib/web/ui-i18n.mjs +286 -8
  104. package/lib/web/ui-interactions.mjs +6 -4
  105. package/lib/web/ui-native-timeline.mjs +326 -0
  106. package/lib/web/ui-native.mjs +115 -51
  107. package/lib/web/ui-review.mjs +5 -3
  108. package/lib/web/ui-safe-markdown.mjs +79 -0
  109. package/lib/web/ui-session-tools.mjs +224 -0
  110. package/lib/web/ui-template.mjs +29 -1969
  111. package/lib/web/ui-terminal-find.mjs +5 -3
  112. package/lib/web/ui-workbench.mjs +5 -3
  113. package/lib/web/ui-workspace.mjs +9 -2
  114. package/package.json +8 -2
  115. package/scripts/benchmark-session-sync.mjs +53 -0
  116. package/scripts/codex-protocol.mjs +88 -0
  117. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  118. package/scripts/dsh-package-provenance.mjs +118 -0
  119. package/scripts/helpers/gc-clock-observation.mjs +33 -0
  120. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  121. package/scripts/native-fixture-root.mjs +49 -0
  122. package/scripts/native-installed-acceptance.mjs +284 -25
  123. package/scripts/native-stability-checks.mjs +40 -0
  124. package/scripts/regression-cookie-expiry.mjs +70 -0
  125. package/scripts/regression-http-transport.mjs +9 -0
  126. package/scripts/regression-terminal-marker.mjs +95 -0
  127. package/scripts/regression.mjs +655 -188
  128. package/scripts/shim-reentry-probe.mjs +105 -0
  129. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  130. package/scripts/web-codex-account-acceptance.mjs +178 -0
  131. package/scripts/web-file-preview-checks.mjs +302 -0
  132. package/scripts/web-mcp-form-acceptance.mjs +22 -7
  133. package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
  134. package/scripts/web-native-handoff-checks.mjs +128 -0
  135. package/scripts/web-native-interaction-acceptance.mjs +152 -18
  136. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  137. package/scripts/web-native-model-acceptance.mjs +22 -7
  138. package/scripts/web-native-retry-checks.mjs +177 -0
  139. package/scripts/web-session-tools-checks.mjs +112 -0
  140. package/scripts/web-workbench-acceptance.mjs +438 -0
  141. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -8,13 +8,21 @@ 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';
20
+ import { readOwnedReentryOutputAtDeadline, readReentryTrace } from './shim-reentry-probe.mjs';
21
+ import { createCookieExpiryWindow } from './regression-cookie-expiry.mjs';
22
+ import { waitForTerminalMarker, terminalMarkerFailureDiagnostic } from './regression-terminal-marker.mjs';
23
+ import { fixtureFetch as fetch } from './regression-http-transport.mjs';
24
+ import { parseShutdownDiagnostics } from '../lib/web/shutdown-diagnostics.mjs';
25
+ import { assertGcPreviewMetadata } from './helpers/gc-clock-observation.mjs';
18
26
 
19
27
  const repoRoot = path.resolve(import.meta.dirname, '..');
20
28
  const hccBin = path.join(repoRoot, 'bin', 'hcc.mjs');
@@ -27,16 +35,24 @@ const tmuxSession = `hcc-reg-${process.pid}`;
27
35
  const tmuxSocketName = `hcc-reg-${testId}`.replace(/[^A-Za-z0-9_-]/g, '-');
28
36
  const secondProjectRoot = path.join(root, 'second-project');
29
37
  const TERMINAL_WEBSOCKET_TIMEOUT_MS = 15_000;
38
+ const COOKIE_WEBSOCKET_SNAPSHOT_TIMEOUT_MS = 5_000;
39
+ // now() uses whole seconds: TTL=1 may leave only 1ms before expiry. Reserve
40
+ // the existing snapshot budget plus clock quantization/setup margin first.
41
+ const COOKIE_EXPIRY_FIXTURE_TTL_SEC = Math.ceil(COOKIE_WEBSOCKET_SNAPSHOT_TIMEOUT_MS / 1000) + 2;
30
42
  const realHome = process.env.HOME || os.homedir();
31
43
  const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
32
- const realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
33
- encoding: 'utf8',
34
- stdio: ['ignore', 'pipe', 'ignore']
35
- }).stdout.trim();
44
+ let realTmuxBin = '';
36
45
  const port = 22000 + (process.pid % 10000);
37
46
 
38
47
  let tmuxStarted = false;
39
48
  let runtimePid = null;
49
+ let runtimeOwner = null;
50
+ let currentStage = 'startup';
51
+ let lastStopDiagnostic = null;
52
+ const diagnosticSecrets = new Set();
53
+ const fixtureFailures = new WeakSet();
54
+ const fixtureCommandFailures = new WeakMap();
55
+ const fixtureHttpFailures = new WeakMap();
40
56
  const managedTmuxSessions = new Set();
41
57
 
42
58
  const env = {
@@ -50,22 +66,300 @@ for (const key of Object.keys(env)) {
50
66
  if (key.startsWith('HCC_')) delete env[key];
51
67
  }
52
68
  if (process.env.HCC_REGRESSION_DEBUG === '1') env.HCC_DEBUG = '1';
69
+ env.HCC_SHUTDOWN_DIAGNOSTICS = '1';
53
70
  delete env.TMUX;
54
71
  delete env.TMUX_PANE;
55
72
 
56
73
  function log(message) {
74
+ currentStage = String(message);
57
75
  process.stdout.write(`${message}\n`);
58
76
  }
59
77
 
60
- function fail(message) {
78
+ function fail(message, commandResult = null, diagnosticOperation = null) {
79
+ const error = fixtureFailureError(message, commandResult, diagnosticOperation);
61
80
  if (process.env.GITHUB_ACTIONS === 'true') {
62
- const escaped = String(message)
81
+ const escaped = fixtureDiagnosticText({ stage: currentStage, failure: fixtureFailureDiagnostic(error) }, diagnosticSecrets)
63
82
  .replace(/%/g, '%25')
64
83
  .replace(/\r/g, '%0D')
65
84
  .replace(/\n/g, '%0A');
66
85
  process.stderr.write(`::error::${escaped}\n`);
67
86
  }
68
- throw new Error(message);
87
+ throw error;
88
+ }
89
+
90
+ export function fixtureFailureError(message, commandResult = null, diagnosticOperation = null) {
91
+ const error = new Error(String(message));
92
+ Object.defineProperty(error, 'fixtureStack', {
93
+ value: Object.freeze(new Error('fixture failure').stack.split('\n').slice(1, 7))
94
+ });
95
+ fixtureFailures.add(error);
96
+ const command = fixtureCommandResult(commandResult, diagnosticOperation);
97
+ if (command) fixtureCommandFailures.set(error, command);
98
+ return error;
99
+ }
100
+
101
+ export function fixtureFailureDiagnostic(error) {
102
+ const names = ['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AssertionError', 'AbortError'];
103
+ const terminalMarker = terminalMarkerFailureDiagnostic(error);
104
+ return {
105
+ name: names.includes(error?.name) ? error.name : 'Error',
106
+ stack: fixtureFailures.has(error)
107
+ ? error.fixtureStack
108
+ : new Error('regression failure handler').stack.split('\n').slice(1, 7),
109
+ ...(fixtureCommandFailures.has(error) ? { command: fixtureCommandFailures.get(error) } : {}),
110
+ ...(fixtureHttpFailures.has(error) ? { http: fixtureHttpFailures.get(error) } : {}),
111
+ ...(terminalMarker ? { terminalMarker } : {})
112
+ };
113
+ }
114
+
115
+ function fixtureHttpRoute(route) {
116
+ if (typeof route !== 'string') return null;
117
+ const pathname = route.split(/[?#]/, 1)[0];
118
+ if (['/', '/api/projects', '/api/runtime', '/api/runtime/gc-buffers', '/api/detected',
119
+ '/api/sessions', '/api/sessions/attach', '/api/resumable',
120
+ '/assets/web/browser/core.mjs', '/assets/web/ui-agent-start.mjs'].includes(pathname)) return pathname;
121
+ const action = pathname.match(/^\/api\/peers\/[^/?#]+\/actions\/(status|state|inbox|heartbeat|task-next|task-takeover|lock-acquire)$/);
122
+ if (action) return `/api/peers/:peer/actions/${action[1]}`;
123
+ const session = pathname.match(/^\/api\/sessions\/[^/?#]+\/(input|stop)$/);
124
+ if (session) return `/api/sessions/:peer/${session[1]}`;
125
+ if (/^\/api\/detected\/[^/?#]+\/msg$/.test(pathname)) return '/api/detected/:peer/msg';
126
+ return null;
127
+ }
128
+
129
+ function fixtureHttpCallFrames() {
130
+ // Capture our call site before invoking fetch. Never parse the thrown error's
131
+ // stack, whose text can contain URLs, request data or third-party messages.
132
+ const capture = {};
133
+ Error.captureStackTrace(capture, withFixtureHttpDiagnostic);
134
+ const source = pathToFileURL(path.join(repoRoot, 'scripts', 'regression.mjs')).href;
135
+ const location = new RegExp(`(?:^|[ (])${source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(\\d+):(\\d+)\\)?$`);
136
+ return Object.freeze(String(capture.stack).split('\n').flatMap((frame) => {
137
+ const match = frame.match(location);
138
+ if (!match) return [];
139
+ return [Object.freeze({ file: 'scripts/regression.mjs', line: Number(match[1]), column: Number(match[2]) })];
140
+ }).slice(0, 6));
141
+ }
142
+
143
+ function fixtureHttpNetworkCode(error) {
144
+ const allowed = ['ECONNRESET', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND',
145
+ 'EAI_AGAIN', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
146
+ 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET'];
147
+ // Reading data descriptors avoids evaluating arbitrary error/cause getters.
148
+ try {
149
+ const direct = Object.getOwnPropertyDescriptor(error, 'code')?.value;
150
+ const cause = Object.getOwnPropertyDescriptor(error, 'cause')?.value;
151
+ const nested = cause && Object.getOwnPropertyDescriptor(cause, 'code')?.value;
152
+ return allowed.includes(direct) ? direct : allowed.includes(nested) ? nested : null;
153
+ } catch { return null; }
154
+ }
155
+
156
+ export async function withFixtureHttpDiagnostic(route, options, request) {
157
+ const method = options?.method ?? 'GET';
158
+ const context = {
159
+ method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].includes(method) ? method : null,
160
+ route: fixtureHttpRoute(route),
161
+ callFrames: fixtureHttpCallFrames()
162
+ };
163
+ const started = performance.now();
164
+ try {
165
+ return await request();
166
+ } catch (error) {
167
+ if (error && (typeof error === 'object' || typeof error === 'function') && !fixtureHttpFailures.has(error)) {
168
+ fixtureHttpFailures.set(error, Object.freeze({ ...context,
169
+ code: fixtureHttpNetworkCode(error), elapsedMs: Math.max(0, Math.round(performance.now() - started)) }));
170
+ }
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ export function fixtureDiagnosticText(value, secrets = []) {
176
+ function scrub(text) {
177
+ for (const secret of secrets) {
178
+ if (typeof secret === 'string' && secret.length >= 8) text = text.split(secret).join('[REDACTED]');
179
+ }
180
+ return redactSecrets(text)
181
+ .replace(/("(?:[^"\\]*token|authorization|cookie|[^"\\]*secret|password)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, '$1"[REDACTED]"');
182
+ }
183
+ function visit(entry) {
184
+ if (typeof entry === 'string') return scrub(entry);
185
+ if (Array.isArray(entry)) return entry.map(visit);
186
+ if (entry && typeof entry === 'object') return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, visit(item)]));
187
+ return entry;
188
+ }
189
+ return typeof value === 'string' ? scrub(value) : JSON.stringify(visit(redactSecrets(value)));
190
+ }
191
+
192
+ function fixturePsState(pid) {
193
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], {
194
+ encoding: 'utf8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore']
195
+ });
196
+ const state = String(result.stdout || '').trim();
197
+ return result.status === 0 && /^[A-Za-z+<>NslLWE-]{1,16}$/.test(state) ? state : null;
198
+ }
199
+
200
+ export function observeFixtureOwner(owner, { inspect = inspectProcessIdentity, psState = fixturePsState } = {}) {
201
+ if (compareProcessIdentity(owner, owner) !== 'live') return { state: 'unknown', reason: 'missing_owner_identity' };
202
+ const observed = inspect(owner.pid);
203
+ const state = psState(owner.pid);
204
+ const details = { pid: owner.pid, psState: state, observedState: observed.state };
205
+ if (observed.state === 'dead') return { ...details, state: 'exited', reason: 'process_dead' };
206
+ if (/^[ZX]/i.test(state || '')) return { ...details, state: 'exited', reason: 'zombie_or_dead' };
207
+ if (observed.state !== 'live' || compareProcessIdentity(observed.identity, observed.identity) !== 'live') {
208
+ return { ...details, state: 'unknown', reason: 'identity_unavailable' };
209
+ }
210
+ details.birthHash = crypto.createHash('sha256').update(observed.identity.startToken).digest('hex');
211
+ details.commandHash = observed.identity.commandHash;
212
+ const comparison = compareProcessIdentity(owner, observed.identity);
213
+ if (comparison === 'dead') return { ...details, state: 'exited', reason: 'pid_reused' };
214
+ if (comparison !== 'live') return { ...details, state: 'unknown', reason: 'identity_incompatible' };
215
+ if (owner.commandHash !== observed.identity.commandHash) return { ...details, state: 'unknown', reason: 'command_changed' };
216
+ return { ...details, state: 'same-owner', reason: 'identity_match' };
217
+ }
218
+
219
+ export function signalFixtureOwner(owner, signal, { observe = observeFixtureOwner, send = process.kill.bind(process) } = {}) {
220
+ const observation = observe(owner);
221
+ if (observation.state !== 'same-owner') return { signal, sent: false, observation };
222
+ try { send(owner.pid, signal); return { signal, sent: true, observation }; }
223
+ catch (error) { return { signal, sent: false, observation, code: error.code || 'SIGNAL_FAILED' }; }
224
+ }
225
+
226
+ export async function waitForFixtureOwnerExit(owner, timeoutMs, {
227
+ observe = observeFixtureOwner, monotonicNow = () => performance.now(), wait = sleep
228
+ } = {}) {
229
+ const deadline = monotonicNow() + timeoutMs;
230
+ for (;;) {
231
+ const observation = observe(owner);
232
+ if (observation.state === 'exited') return observation;
233
+ const remaining = deadline - monotonicNow();
234
+ if (remaining <= 0) {
235
+ const error = new Error('runtime fixture owner did not exit before deadline');
236
+ error.observation = observation;
237
+ throw error;
238
+ }
239
+ await wait(Math.min(100, remaining));
240
+ }
241
+ }
242
+
243
+ function fixtureCliFailurePayload(output) {
244
+ if (typeof output !== 'string' || Buffer.byteLength(output) > 64 * 1024) return null;
245
+ const text = output.trim();
246
+ const parse = value => {
247
+ try {
248
+ const payload = JSON.parse(value);
249
+ return payload?.ok === false && !Array.isArray(payload) && payload.error && typeof payload.error === 'object' &&
250
+ !Array.isArray(payload.error) ? payload : null;
251
+ } catch { return null; }
252
+ };
253
+ const direct = parse(text);
254
+ if (direct) return direct;
255
+ // Node warnings can precede or follow the CLI's complete JSON document.
256
+ // Strip only bounded, recognized edge lines; never scan arbitrary logs for
257
+ // inner objects, which could turn a nested error into the CLI failure.
258
+ const lines = text.split(/\r?\n/);
259
+ let first = 0, last = lines.length, warningLines = 0, warningBytes = 0;
260
+ const warning = line =>
261
+ /^\(node:\d+\) (?:\[[A-Z][A-Z0-9_]*\] )?(?:[A-Za-z]+Warning|Warning):/.test(line) ||
262
+ /^\(Use (?:`node --trace-(?:warnings|deprecation) \.\.\.`|node --trace-(?:warnings|deprecation)(?: \.\.\.)?) to show where the warning was created\)$/.test(line);
263
+ const strip = line => {
264
+ const trimmed = line.trim();
265
+ if (trimmed && !warning(trimmed)) return false;
266
+ if (trimmed) warningLines += 1;
267
+ warningBytes += Buffer.byteLength(line) + 1;
268
+ return true;
269
+ };
270
+ while (first < last && strip(lines[first])) first += 1;
271
+ while (first < last && strip(lines[last - 1])) last -= 1;
272
+ if (warningLines > 8 || warningBytes > 4096) return null;
273
+ return parse(lines.slice(first, last).join('\n'));
274
+ }
275
+
276
+ function fixtureCliResultParts(result) {
277
+ // --json failures are written with console.error; stdout is retained as a
278
+ // compatibility fallback for fixtures. Raw output never enters diagnostics.
279
+ const stderrFailure = fixtureCliFailurePayload(result.stderr);
280
+ const stdoutFailure = fixtureCliFailurePayload(result.stdout);
281
+ const payload = stderrFailure || stdoutFailure;
282
+ const extra = payload?.error?.extra || payload?.error || {};
283
+ const code = payload?.error?.code || result.error?.code;
284
+ const diagnostic = {
285
+ status: Number.isSafeInteger(result.status) ? result.status : null,
286
+ signal: typeof result.signal === 'string' && Object.hasOwn(os.constants.signals, result.signal) ? result.signal : null,
287
+ code: typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(code) ? code : null,
288
+ source: stderrFailure ? 'stderr' : stdoutFailure ? 'stdout' : result.error?.code ? 'spawn' : null,
289
+ stdoutBytes: typeof result.stdout === 'string' ? Buffer.byteLength(result.stdout) : 0,
290
+ stderrBytes: typeof result.stderr === 'string' ? Buffer.byteLength(result.stderr) : 0
291
+ };
292
+ return { diagnostic, extra };
293
+ }
294
+
295
+ export function fixtureDownResult(result) {
296
+ const { diagnostic, extra } = fixtureCliResultParts(result);
297
+ const numeric = ['pid', 'elapsedMs', 'timeoutMs', 'stopElapsedMs', 'confirmationMs'];
298
+ return {
299
+ ...diagnostic,
300
+ extra: {
301
+ ...Object.fromEntries(numeric.filter(key => typeof extra[key] === 'number' &&
302
+ Number.isFinite(extra[key]) && extra[key] >= 0).map(key => [key, extra[key]])),
303
+ ...(['live', 'dead', 'unknown'].includes(extra.state) ? { state: extra.state } : {}),
304
+ ...(['evidence_unavailable', 'exit_unconfirmed', 'pointer_cleanup_failed', 'pointer_changed'].includes(extra.stopPhase)
305
+ ? { stopPhase: extra.stopPhase } : {})
306
+ }
307
+ };
308
+ }
309
+
310
+ function fixtureCommandResult(result, operation) {
311
+ // Only this explicit fixture operation opts in. Never copy command arguments,
312
+ // error messages, output, URLs or peer identifiers into public annotations.
313
+ if (operation !== 'broadcast' || !result || typeof result !== 'object') return null;
314
+ const { diagnostic, extra } = fixtureCliResultParts(result);
315
+ const context = Object.fromEntries(['elapsedMs', 'timeoutMs'].filter(key =>
316
+ Number.isSafeInteger(extra[key]) && extra[key] >= 0).map(key => [key, extra[key]]));
317
+ if (Number.isSafeInteger(extra.status) && extra.status >= 100 && extra.status <= 599) context.status = extra.status;
318
+ if (extra.method === 'GET' || extra.method === 'POST') context.method = extra.method;
319
+ const route = typeof extra.path === 'string' ? extra.path.split(/[?#]/, 1)[0] : '';
320
+ if (route === '/api/sessions') context.path = route;
321
+ else if (/^\/api\/sessions\/[^/?#]+\/input$/.test(route)) context.path = '/api/sessions/:peer/input';
322
+ return Object.freeze({ operation, ...diagnostic, extra: Object.freeze(context) });
323
+ }
324
+
325
+ export function fixtureStopEnvironment(base) {
326
+ const local = { ...base, HCC_RUNTIME_LOCAL_ONLY: '1' };
327
+ delete local.HCC_RUNTIME_URL;
328
+ delete local.HCC_RUNTIME_TOKEN;
329
+ return local;
330
+ }
331
+
332
+ function rememberRuntimePid(pid) {
333
+ runtimePid = pid;
334
+ runtimeOwner = null;
335
+ try {
336
+ const pointer = currentRuntime();
337
+ if (pointer.token) {
338
+ diagnosticSecrets.add(pointer.token);
339
+ for (const part of pointer.token.split(/\s+/)) if (part.length >= 8) diagnosticSecrets.add(part);
340
+ }
341
+ if (pointer.process_identity?.pid === pid && compareProcessIdentity(pointer.process_identity, pointer.process_identity) === 'live') {
342
+ runtimeOwner = { ...pointer.process_identity };
343
+ }
344
+ } catch {}
345
+ }
346
+
347
+ function collectRuntimeDiagnostic(extra = {}) {
348
+ const diagnostic = { stage: currentStage, pid: runtimePid,
349
+ owner: observeFixtureOwner(runtimeOwner), pointerExists: fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), ...extra };
350
+ let logFd;
351
+ try {
352
+ logFd = fs.openSync(path.join(root, '.hello-cc', 'web.log'), 'r');
353
+ const size = fs.fstatSync(logFd).size, bytes = Buffer.alloc(Math.min(size, 65536));
354
+ fs.readSync(logFd, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
355
+ diagnostic.shutdownPhases = parseShutdownDiagnostics(bytes.toString('utf8'), runtimePid);
356
+ } catch {} finally { if (logFd !== undefined) fs.closeSync(logFd); }
357
+ try {
358
+ const db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { readOnly: true, timeout: 200 });
359
+ try { diagnostic.events = db.prepare('SELECT id, type FROM events ORDER BY id DESC LIMIT 12').all(); }
360
+ finally { db.close(); }
361
+ } catch {}
362
+ return fixtureDiagnosticText(diagnostic, diagnosticSecrets);
69
363
  }
70
364
 
71
365
  function commandText(command, args) {
@@ -81,6 +375,10 @@ function canonicalPath(value) {
81
375
  catch { return path.resolve(value); }
82
376
  }
83
377
 
378
+ export function isRegressionEntry(entry = process.argv[1], filename = import.meta.filename) {
379
+ return Boolean(entry) && canonicalPath(entry) === canonicalPath(filename);
380
+ }
381
+
84
382
  function samePath(a, b) {
85
383
  return canonicalPath(a) === canonicalPath(b);
86
384
  }
@@ -117,7 +415,7 @@ function run(command, args, options = {}) {
117
415
  });
118
416
  if (result.status !== 0) {
119
417
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
120
- fail(`${commandText(command, args)} failed${output ? `\n${output}` : ''}`);
418
+ fail(`${commandText(command, args)} failed${output ? `\n${output}` : ''}`, result, options.diagnosticOperation);
121
419
  }
122
420
  return result.stdout || '';
123
421
  }
@@ -1422,11 +1720,13 @@ function currentRuntimeUrl(route, params = {}) {
1422
1720
  return runtimeUrl(currentRuntime(), route, params);
1423
1721
  }
1424
1722
 
1425
- function runtimeFetch(route, options = {}, params = {}) {
1426
- const runtime = currentRuntime();
1427
- const headers = { ...(options.headers || {}), 'X-HCC-API-Version': '2' };
1428
- if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
1429
- return fetch(runtimeUrl(runtime, route, params), { ...options, headers });
1723
+ export function runtimeFetch(route, options = {}, params = {}) {
1724
+ return withFixtureHttpDiagnostic(route, options, () => {
1725
+ const runtime = currentRuntime();
1726
+ const headers = { ...(options.headers || {}), 'X-HCC-API-Version': '2' };
1727
+ if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
1728
+ return fetch(runtimeUrl(runtime, route, params), { ...options, headers });
1729
+ });
1430
1730
  }
1431
1731
 
1432
1732
  function directTlsRequest(runtime, route, options = {}) {
@@ -1434,6 +1734,7 @@ function directTlsRequest(runtime, route, options = {}) {
1434
1734
  const body = options.body === undefined || options.body === null ? null : String(options.body);
1435
1735
  return new Promise((resolve, reject) => {
1436
1736
  const req = https.request({
1737
+ agent: false,
1437
1738
  method: options.method || 'GET',
1438
1739
  hostname: url.hostname,
1439
1740
  port: url.port || 443,
@@ -1521,17 +1822,37 @@ async function waitForFile(file, expected, label = file) {
1521
1822
  ensureFile(file, expected);
1522
1823
  }
1523
1824
 
1524
- async function waitForFileContent(file, expected, label = file) {
1525
- const deadline = Date.now() + 10000;
1825
+ async function waitForFileContent(file, expected, label = file, { diagnostics = null, ownedReentryOutput = false } = {}) {
1826
+ const startedAt = Date.now();
1827
+ const deadline = startedAt + 10000;
1526
1828
  let actual = '(missing)';
1829
+ let lastReadAt = null;
1527
1830
  while (Date.now() < deadline) {
1528
1831
  if (fs.existsSync(file)) {
1832
+ lastReadAt = Date.now();
1529
1833
  actual = fs.readFileSync(file, 'utf8').trim();
1530
1834
  if (actual === expected) break;
1531
1835
  }
1532
1836
  await sleep(100);
1533
1837
  }
1838
+ let finalObservation = null;
1839
+ if (actual !== expected && ownedReentryOutput) {
1840
+ finalObservation = readOwnedReentryOutputAtDeadline(file, expected, deadline, Date.now());
1841
+ if (finalObservation.matched) actual = expected;
1842
+ }
1534
1843
  if (actual !== expected) {
1844
+ if (diagnostics) {
1845
+ const detail = JSON.stringify({ ...diagnostics(), wait: {
1846
+ started_at_ms: startedAt, deadline_ms: deadline, last_read_at_ms: lastReadAt,
1847
+ final_observation: finalObservation
1848
+ } });
1849
+ if (process.env.GITHUB_ACTIONS === 'true') {
1850
+ const escaped = detail.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
1851
+ process.stderr.write(`::error title=Shim regression diagnostic::${escaped}\n`);
1852
+ } else {
1853
+ process.stderr.write(`Shim regression diagnostic: ${detail}\n`);
1854
+ }
1855
+ }
1535
1856
  fail(`timed out waiting for ${label}\nexpected: ${expected}\nactual: ${actual}`);
1536
1857
  }
1537
1858
  ensureFile(file, expected);
@@ -1572,9 +1893,9 @@ function assertHtmlCsp(response, html, label) {
1572
1893
  ? "default-src 'self'; " +
1573
1894
  `script-src 'self' 'nonce-${nonce}'; ` +
1574
1895
  "style-src 'self' 'unsafe-inline'; " +
1575
- "img-src 'self' data:; " +
1896
+ "img-src 'self' data: blob:; " +
1576
1897
  "connect-src 'self' ws: wss:; " +
1577
- "object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
1898
+ "object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'self' blob:"
1578
1899
  : '';
1579
1900
  if (!nonce || policy !== expected) {
1580
1901
  fail(`${label} missing complete nonce CSP: ${policy || '(missing)'}`);
@@ -1588,15 +1909,7 @@ function assertHtmlCsp(response, html, label) {
1588
1909
  }
1589
1910
 
1590
1911
  function processIsAlive(pid) {
1591
- try {
1592
- process.kill(pid, 0);
1593
- // Linux keeps an exited child addressable until PID 1 reaps its zombie.
1594
- // Product liveness treats that state as dead, and the regression wait must
1595
- // use the same semantic boundary instead of waiting forever on kill(0).
1596
- return process.platform !== 'linux' || inspectProcessIdentity(pid).state !== 'dead';
1597
- } catch {
1598
- return false;
1599
- }
1912
+ return inspectProcessIdentity(pid).state !== 'dead' && !/^[ZX]/i.test(fixturePsState(pid) || '');
1600
1913
  }
1601
1914
 
1602
1915
  async function waitForProcessExit(pid, label, timeoutMs = 5000) {
@@ -1604,34 +1917,10 @@ async function waitForProcessExit(pid, label, timeoutMs = 5000) {
1604
1917
  }
1605
1918
 
1606
1919
  async function expectWebSocketMarker(peer, marker) {
1607
- await new Promise((resolve, reject) => {
1608
- let sawSnapshot = false;
1609
- let sawMarker = false;
1610
- const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
1611
- const timer = setTimeout(() => {
1612
- try { ws.terminate(); } catch {}
1613
- reject(new Error(`${peer} websocket timeout`));
1614
- }, TERMINAL_WEBSOCKET_TIMEOUT_MS);
1615
- ws.on('message', (raw) => {
1616
- const msg = JSON.parse(String(raw));
1617
- if (msg.type === 'snapshot') {
1618
- sawSnapshot = true;
1619
- claimTerminalControl(ws).then(() => ws.send(JSON.stringify({
1620
- type: 'input', data: `echo ${marker}\r`, action_token: ws.hccActionToken,
1621
- epoch: ws.hccControl.epoch
1622
- }))).catch(reject);
1623
- }
1624
- if (['snapshot', 'data', 'replace'].includes(msg.type) && String(msg.data || '').includes(marker)) {
1625
- sawMarker = true;
1626
- }
1627
- if (sawSnapshot && sawMarker) {
1628
- clearTimeout(timer);
1629
- releaseTerminalControl(ws);
1630
- ws.close();
1631
- resolve();
1632
- }
1633
- });
1634
- ws.on('error', (err) => { clearTimeout(timer); reject(err); });
1920
+ const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
1921
+ await waitForTerminalMarker(ws, marker, {
1922
+ claim: signal => claimTerminalControl(ws, { signal }),
1923
+ release: () => releaseTerminalControl(ws), timeoutMs: TERMINAL_WEBSOCKET_TIMEOUT_MS
1635
1924
  });
1636
1925
  }
1637
1926
 
@@ -1641,31 +1930,44 @@ function trackTerminalControl(ws) {
1641
1930
  ws.on('message', (raw) => {
1642
1931
  let message;
1643
1932
  try { message = JSON.parse(String(raw)); } catch { return; }
1933
+ if (!message || typeof message !== 'object') return;
1644
1934
  if (message.action_token) ws.hccActionToken = message.action_token;
1645
1935
  if (message.control) ws.hccControl = message.control;
1646
1936
  });
1647
1937
  return ws;
1648
1938
  }
1649
1939
 
1650
- async function claimTerminalControl(ws) {
1940
+ export async function claimTerminalControl(ws, { signal } = {}) {
1941
+ const aborted = () => Object.assign(new Error('terminal control claim aborted'), { code: 'TERMINAL_CONTROL_ABORTED' });
1942
+ if (signal?.aborted) throw aborted();
1651
1943
  if (ws.hccControl?.can_control) return ws.hccControl;
1652
1944
  return new Promise((resolve, reject) => {
1653
1945
  const timer = setTimeout(() => finish(new Error('terminal control claim timeout')), 5000);
1946
+ let settled = false;
1654
1947
  const finish = (error = null) => {
1948
+ if (settled) return;
1949
+ settled = true;
1655
1950
  clearTimeout(timer);
1656
1951
  ws.off('message', onMessage);
1952
+ signal?.removeEventListener('abort', onAbort);
1657
1953
  if (error) reject(error);
1658
1954
  else resolve(ws.hccControl);
1659
1955
  };
1660
1956
  const onMessage = (raw) => {
1957
+ if (settled) return;
1661
1958
  let message;
1662
1959
  try { message = JSON.parse(String(raw)); } catch { return; }
1960
+ if (!message || typeof message !== 'object') return;
1663
1961
  if (message.type === 'error') finish(new Error(`terminal control claim failed: ${JSON.stringify(message.error)}`));
1664
1962
  else if (message.type === 'control' && message.control?.can_control) finish();
1665
1963
  };
1964
+ const onAbort = () => finish(aborted());
1666
1965
  ws.on('message', onMessage);
1667
- ws.send(JSON.stringify({ type: 'control', action: 'claim', action_token: ws.hccActionToken,
1668
- epoch: ws.hccControl?.epoch, force: true }));
1966
+ signal?.addEventListener('abort', onAbort, { once: true });
1967
+ try {
1968
+ ws.send(JSON.stringify({ type: 'control', action: 'claim', action_token: ws.hccActionToken,
1969
+ epoch: ws.hccControl?.epoch, force: true }));
1970
+ } catch (error) { finish(error); }
1669
1971
  });
1670
1972
  }
1671
1973
 
@@ -1778,24 +2080,60 @@ async function fetchTerminalSnapshot(peer, params = {}) {
1778
2080
  });
1779
2081
  }
1780
2082
 
1781
- async function issueBrowserSessionCookie() {
2083
+ function browserSessionCookie(setCookie, endpoint, { expired = false } = {}) {
2084
+ const url = new URL(endpoint);
2085
+ const protocol = url.protocol.slice(0, -1);
2086
+ const effectivePort = url.port || (protocol === 'https' ? '443' : '80');
2087
+ const expectedName = `hcc_sid_v2_${protocol}_${effectivePort}`;
2088
+ const match = String(setCookie).match(/^([^=;\s]+)=([^;]*)/);
2089
+ if (!match || match[1] !== expectedName || (expired ? match[2] !== '' : !match[2])) {
2090
+ fail(`browser session cookie did not match endpoint scope ${expectedName} (expired=${expired})`);
2091
+ }
2092
+ return { name: match[1], sid: match[2], cookie: `${match[1]}=${match[2]}` };
2093
+ }
2094
+
2095
+ async function selectCookieProject(baseUrl, cookie, projectRoot = root, {
2096
+ signal, origin = new URL(baseUrl).origin, headers = {}
2097
+ } = {}) {
2098
+ const url = new URL('/api/projects/select', baseUrl);
2099
+ url.searchParams.set('root', projectRoot);
2100
+ const response = await fetch(url, {
2101
+ method: 'POST',
2102
+ headers: {
2103
+ ...headers,
2104
+ Cookie: cookie,
2105
+ Origin: origin,
2106
+ 'X-HCC-API-Version': '2'
2107
+ },
2108
+ signal
2109
+ });
2110
+ const selected = await response.json();
2111
+ if (!response.ok || !selected.project_identity) {
2112
+ fail(`cookie project selection failed: status=${response.status} body=${JSON.stringify(selected)}`);
2113
+ }
2114
+ return selected.project_identity;
2115
+ }
2116
+
2117
+ async function issueBrowserSessionCookie({ signal, selectProject = true } = {}) {
1782
2118
  const runtime = currentRuntime();
1783
2119
  const baseUrl = runtime.base_url || `http://127.0.0.1:${port}`;
1784
2120
  const response = await fetch(new URL('/login', baseUrl), {
1785
2121
  method: 'POST',
1786
2122
  headers: { 'Content-Type': 'application/json' },
1787
2123
  body: JSON.stringify({ token: runtime.token || '' }),
2124
+ signal,
1788
2125
  redirect: 'manual'
1789
2126
  });
1790
2127
  const setCookie = response.headers.get('set-cookie') || '';
1791
- const sid = setCookie.match(/hcc_sid=([^;]+)/)?.[1] || '';
1792
- if (response.status !== 302 || !sid) {
1793
- fail(`browser session login failed: status=${response.status} cookie=${setCookie}`);
2128
+ if (response.status !== 302) {
2129
+ fail(`browser session login failed: status=${response.status}`);
1794
2130
  }
1795
- return { baseUrl, origin: new URL(baseUrl).origin, sid };
2131
+ const auth = browserSessionCookie(setCookie, baseUrl);
2132
+ const projectIdentity = selectProject ? await selectCookieProject(baseUrl, auth.cookie, root, { signal }) : null;
2133
+ return { baseUrl, origin: new URL(baseUrl).origin, ...auth, projectIdentity };
1796
2134
  }
1797
2135
 
1798
- async function cookieRuntimeFetch(route, auth, options = {}, params = {}) {
2136
+ export async function cookieRuntimeFetch(route, auth, options = {}, params = {}) {
1799
2137
  const url = new URL(route, auth.baseUrl);
1800
2138
  for (const [key, value] of Object.entries(params)) {
1801
2139
  if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
@@ -1803,9 +2141,10 @@ async function cookieRuntimeFetch(route, auth, options = {}, params = {}) {
1803
2141
  return fetch(url, {
1804
2142
  ...options,
1805
2143
  headers: {
1806
- Cookie: `hcc_sid=${auth.sid}`,
2144
+ Cookie: auth.cookie,
1807
2145
  Origin: auth.origin,
1808
2146
  'X-HCC-API-Version': '2',
2147
+ ...(auth.projectIdentity ? { 'X-HCC-Root-Identity': auth.projectIdentity } : {}),
1809
2148
  ...(options.headers || {})
1810
2149
  }
1811
2150
  });
@@ -1819,8 +2158,9 @@ async function cookieRuntimeFetchWithoutOrigin(route, auth, options = {}, params
1819
2158
  return fetch(url, {
1820
2159
  ...options,
1821
2160
  headers: {
1822
- Cookie: `hcc_sid=${auth.sid}`,
2161
+ Cookie: auth.cookie,
1823
2162
  'X-HCC-API-Version': '2',
2163
+ ...(auth.projectIdentity ? { 'X-HCC-Root-Identity': auth.projectIdentity } : {}),
1824
2164
  ...(options.headers || {})
1825
2165
  }
1826
2166
  });
@@ -1846,9 +2186,10 @@ async function expectSocketMarkerAfter(ws, marker, action) {
1846
2186
  });
1847
2187
  }
1848
2188
 
1849
- async function openCookieTerminalWebSocket(peer, sid, params = {}) {
2189
+ async function openCookieTerminalWebSocket(peer, cookie, params = {}, selectedProjectIdentity = null) {
1850
2190
  const runtime = currentRuntime();
1851
2191
  const baseUrl = runtime.base_url || `http://127.0.0.1:${port}`;
2192
+ const projectIdentity = selectedProjectIdentity || await selectCookieProject(baseUrl, cookie, params.root || root);
1852
2193
  const url = new URL(`/ws/terminal/${encodeURIComponent(peer)}`, baseUrl);
1853
2194
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1854
2195
  url.searchParams.set('api_version', '2');
@@ -1859,8 +2200,9 @@ async function openCookieTerminalWebSocket(peer, sid, params = {}) {
1859
2200
  let settled = false;
1860
2201
  const ws = trackTerminalControl(new WebSocket(url, {
1861
2202
  headers: {
1862
- Cookie: `hcc_sid=${sid}`,
1863
- Origin: new URL(baseUrl).origin
2203
+ Cookie: cookie,
2204
+ Origin: new URL(baseUrl).origin,
2205
+ 'X-HCC-Root-Identity': projectIdentity
1864
2206
  }
1865
2207
  }));
1866
2208
  const timer = setTimeout(() => {
@@ -1868,7 +2210,7 @@ async function openCookieTerminalWebSocket(peer, sid, params = {}) {
1868
2210
  settled = true;
1869
2211
  try { ws.terminate(); } catch {}
1870
2212
  reject(new Error(`${peer} cookie websocket snapshot timeout`));
1871
- }, 5000);
2213
+ }, COOKIE_WEBSOCKET_SNAPSHOT_TIMEOUT_MS);
1872
2214
  const rejectBeforeSnapshot = (err) => {
1873
2215
  if (settled) return;
1874
2216
  settled = true;
@@ -1900,12 +2242,12 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
1900
2242
  redirect: 'manual'
1901
2243
  });
1902
2244
  const setCookie = login.headers.get('set-cookie') || '';
1903
- const sid = setCookie.match(/hcc_sid=([^;]+)/)?.[1] || '';
1904
- if (login.status !== 302 || !sid) {
1905
- fail(`cookie websocket login failed: status=${login.status} cookie=${setCookie}`);
2245
+ if (login.status !== 302) {
2246
+ fail(`cookie websocket login failed: status=${login.status}`);
1906
2247
  }
2248
+ const auth = browserSessionCookie(setCookie, baseUrl);
1907
2249
 
1908
- const ws = await openCookieTerminalWebSocket(peer, sid, params);
2250
+ const ws = await openCookieTerminalWebSocket(peer, auth.cookie, params);
1909
2251
  try {
1910
2252
  const closed = new Promise((resolve, reject) => {
1911
2253
  const timer = setTimeout(() => reject(new Error(`${peer} cookie websocket logout close timeout`)), 5000);
@@ -1922,7 +2264,7 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
1922
2264
  fetch(new URL('/logout', baseUrl), {
1923
2265
  method: 'POST',
1924
2266
  headers: {
1925
- Cookie: `hcc_sid=${sid}`,
2267
+ Cookie: auth.cookie,
1926
2268
  Origin: origin
1927
2269
  }
1928
2270
  }),
@@ -1931,11 +2273,12 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
1931
2273
  if (logout.status !== 204 || !(logout.headers.get('set-cookie') || '').includes('Max-Age=0')) {
1932
2274
  fail(`cookie websocket logout did not expire its session: status=${logout.status}`);
1933
2275
  }
2276
+ browserSessionCookie(logout.headers.get('set-cookie') || '', baseUrl, { expired: true });
1934
2277
  if (closeResult.code !== 4001 || ws.readyState !== WebSocket.CLOSED) {
1935
2278
  fail(`cookie websocket logout did not close the established socket with 4001:\n${JSON.stringify({ closeResult, readyState: ws.readyState }, null, 2)}`);
1936
2279
  }
1937
2280
  const revoked = await fetch(new URL('/api/runtime', baseUrl), {
1938
- headers: { Cookie: `hcc_sid=${sid}`, 'X-HCC-API-Version': '2' }
2281
+ headers: { Cookie: auth.cookie, 'X-HCC-API-Version': '2' }
1939
2282
  });
1940
2283
  if (revoked.status !== 401) {
1941
2284
  fail(`cookie websocket logout left the old cookie authorized: ${revoked.status}`);
@@ -1949,7 +2292,7 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
1949
2292
 
1950
2293
  async function assertEvictionClosesCookieWebSocket(peer, params = {}) {
1951
2294
  const auth = await issueBrowserSessionCookie();
1952
- const ws = await openCookieTerminalWebSocket(peer, auth.sid, params);
2295
+ const ws = await openCookieTerminalWebSocket(peer, auth.cookie, params, auth.projectIdentity);
1953
2296
  try {
1954
2297
  const closed = new Promise((resolve, reject) => {
1955
2298
  const timer = setTimeout(() => reject(new Error(`${peer} cookie websocket eviction close timeout`)), 10000);
@@ -1963,14 +2306,14 @@ async function assertEvictionClosesCookieWebSocket(peer, params = {}) {
1963
2306
  });
1964
2307
  });
1965
2308
  for (let offset = 0; offset < 256; offset += 32) {
1966
- await Promise.all(Array.from({ length: 32 }, () => issueBrowserSessionCookie()));
2309
+ await Promise.all(Array.from({ length: 32 }, () => issueBrowserSessionCookie({ selectProject: false })));
1967
2310
  }
1968
2311
  const closeResult = await closed;
1969
2312
  if (closeResult.code !== 4001 || !closeResult.reason.includes('session limit reached')) {
1970
2313
  fail(`cookie websocket eviction did not close with 4001:\n${JSON.stringify(closeResult, null, 2)}`);
1971
2314
  }
1972
2315
  const revoked = await fetch(new URL('/api/runtime', auth.baseUrl), {
1973
- headers: { Cookie: `hcc_sid=${auth.sid}`, 'X-HCC-API-Version': '2' }
2316
+ headers: { Cookie: auth.cookie, 'X-HCC-API-Version': '2' }
1974
2317
  });
1975
2318
  if (revoked.status !== 401) fail(`evicted cookie remained authorized: ${revoked.status}`);
1976
2319
  } finally {
@@ -2547,24 +2890,20 @@ function startRuntime(options = {}) {
2547
2890
  const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], { env: runtimeEnv });
2548
2891
  const match = output.match(/^pid:\s*(\d+)/m);
2549
2892
  if (!match) fail(`hcc web did not print background pid:\n${output}`);
2550
- runtimePid = Number.parseInt(match[1], 10);
2893
+ rememberRuntimePid(Number.parseInt(match[1], 10));
2551
2894
  if (!output.includes('web started in background')) fail(`hcc web did not report background start:\n${output}`);
2552
2895
  }
2553
2896
 
2554
2897
  async function cookieSessionExpiryWorkflow() {
2555
2898
  const expirySessionId = `cookie-expiry-${testId}`;
2556
2899
  const marker = `EXPIRED_COOKIE_INPUT_${testId}`;
2557
- // Sessions use whole-second timestamps. A one-second TTL can expire during
2558
- // the initial WebSocket/PTY snapshot, before this test reaches its assertion.
2559
- // Keep a bounded setup window, then explicitly wait past the same TTL.
2560
- const cookieTtlSec = 10;
2561
2900
  let ws = null;
2562
2901
 
2563
2902
  startRuntime({
2564
2903
  env: {
2565
2904
  ...env,
2566
2905
  HCC_REGRESSION_TEST: '1',
2567
- HCC_REGRESSION_WEB_SESSION_TTL_SEC: String(cookieTtlSec)
2906
+ HCC_REGRESSION_WEB_SESSION_TTL_SEC: String(COOKIE_EXPIRY_FIXTURE_TTL_SEC)
2568
2907
  }
2569
2908
  });
2570
2909
  try {
@@ -2590,40 +2929,56 @@ async function cookieSessionExpiryWorkflow() {
2590
2929
  }
2591
2930
 
2592
2931
  const auth = await issueBrowserSessionCookie();
2593
- ws = await openCookieTerminalWebSocket(expirySessionId, auth.sid, { root });
2932
+ ws = await openCookieTerminalWebSocket(expirySessionId, auth.cookie, { root }, auth.projectIdentity);
2594
2933
  if (!ws.hccActionToken) fail('short-TTL cookie terminal snapshot omitted its action token');
2595
- const closed = new Promise((resolve, reject) => {
2596
- const timer = setTimeout(() => reject(new Error('expired cookie websocket close timeout')), (cookieTtlSec + 5) * 1000);
2934
+ const expiryWindow = createCookieExpiryWindow(COOKIE_EXPIRY_FIXTURE_TTL_SEC * 1000);
2935
+ // Listen before waiting so spontaneous expiry is retained. Start the close
2936
+ // deadline only after expiry, and defer rejection to avoid an unhandled
2937
+ // error while this fixture is still waiting for the cookie to expire.
2938
+ const closed = new Promise(resolve => {
2597
2939
  ws.once('close', (code, reason) => {
2598
- clearTimeout(timer);
2599
2940
  resolve({ code, reason: String(reason || '') });
2600
2941
  });
2601
- ws.once('error', (err) => {
2602
- clearTimeout(timer);
2603
- reject(err);
2604
- });
2942
+ ws.once('error', error => resolve({ error }));
2605
2943
  });
2606
2944
 
2607
- await sleep(cookieTtlSec * 1000 + 200);
2945
+ // A probe-only cookie is issued after the target cookie. Observing its
2946
+ // expiry must not revoke the target socket before its input guard runs.
2947
+ const probeAuth = await expiryWindow.prepare(signal => issueBrowserSessionCookie({ signal }));
2948
+ // Cookie expiry uses the server's wall clock, which can advance more slowly
2949
+ // than this fixture's timer. Observe real expiry within the original total
2950
+ // TTL + close budget before sending input on this same socket.
2951
+ await expiryWindow.waitForHttpExpiry(signal =>
2952
+ cookieRuntimeFetch('/api/runtime', probeAuth, { signal }));
2953
+ if (ws.readyState !== WebSocket.OPEN) fail('target cookie websocket closed before the expired-input check');
2608
2954
  ws.send(JSON.stringify({
2609
2955
  type: 'input',
2610
2956
  data: `echo ${marker}\r`,
2611
2957
  action_token: ws.hccActionToken,
2612
2958
  epoch: ws.hccControl.epoch
2613
2959
  }));
2614
- const closeResult = await closed;
2615
- if (closeResult.code !== 4001 || !closeResult.reason.includes('session expired')) {
2616
- fail(`expired cookie websocket did not close with 4001/session expired:\n${JSON.stringify(closeResult, null, 2)}`);
2617
- }
2618
-
2960
+ let closeError = null;
2961
+ const closeResult = await expiryWindow.waitForClose(closed).catch(error => {
2962
+ closeError = error; return null;
2963
+ });
2964
+ // Inspect the HTTP and input boundaries even if the close observation
2965
+ // times out; a missing close event must not hide accepted terminal input.
2619
2966
  const expiredHttp = await cookieRuntimeFetch('/api/runtime', auth);
2967
+ const snapshot = await fetchTerminalSnapshot(expirySessionId, { root });
2968
+ const inputRejected = !snapshot.includes(marker);
2620
2969
  if (expiredHttp.status !== 401) {
2621
2970
  fail(`expired browser cookie remained authorized over HTTP: ${expiredHttp.status}`);
2622
2971
  }
2623
- const snapshot = await fetchTerminalSnapshot(expirySessionId, { root });
2624
- if (snapshot.includes(marker)) {
2972
+ if (!inputRejected) {
2625
2973
  fail(`expired browser cookie executed terminal input after expiry:\n${snapshot}`);
2626
2974
  }
2975
+ if (closeError) {
2976
+ fail(`expired cookie websocket close failed: code=${closeError.code || 'CLOSE_FAILED'} httpStatus=${expiredHttp.status} inputRejected=${inputRejected}`);
2977
+ }
2978
+ if (closeResult.error) throw closeResult.error;
2979
+ if (closeResult.code !== 4001 || !closeResult.reason.includes('session expired')) {
2980
+ fail(`expired cookie websocket did not close with 4001/session expired:\n${JSON.stringify(closeResult, null, 2)}`);
2981
+ }
2627
2982
  } finally {
2628
2983
  if (ws && ws.readyState !== WebSocket.CLOSED) {
2629
2984
  try { ws.terminate(); } catch {}
@@ -2649,7 +3004,7 @@ async function webSecretRedactionWorkflow() {
2649
3004
  ]);
2650
3005
  const match = output.match(/^pid:\s*(\d+)/m);
2651
3006
  if (!match) fail(`redaction web did not print background pid`);
2652
- runtimePid = Number.parseInt(match[1], 10);
3007
+ rememberRuntimePid(Number.parseInt(match[1], 10));
2653
3008
 
2654
3009
  try {
2655
3010
  await waitRuntime();
@@ -2662,8 +3017,8 @@ async function webSecretRedactionWorkflow() {
2662
3017
  body: JSON.stringify({ token: secret }),
2663
3018
  redirect: 'manual'
2664
3019
  });
2665
- const sid = (login.headers.get('set-cookie') || '').match(/hcc_sid=([^;]+)/)?.[1] || '';
2666
- if (login.status !== 302 || !sid) fail(`redaction login failed with status ${login.status}`);
3020
+ if (login.status !== 302) fail(`redaction login failed with status ${login.status}`);
3021
+ const auth = browserSessionCookie(login.headers.get('set-cookie') || '', runtime.base_url);
2667
3022
 
2668
3023
  const create = await runtimeFetch('/api/sessions', {
2669
3024
  method: 'POST',
@@ -2677,7 +3032,7 @@ async function webSecretRedactionWorkflow() {
2677
3032
  })
2678
3033
  }, { root });
2679
3034
  if (!create.ok) fail(`redaction PTY create failed with status ${create.status}`);
2680
- terminalWs = await openCookieTerminalWebSocket(sessionId, sid, { root });
3035
+ terminalWs = await openCookieTerminalWebSocket(sessionId, auth.cookie, { root });
2681
3036
  terminalWs.send(JSON.stringify({ type: 'resize', cols: 90, rows: 28,
2682
3037
  action_token: terminalWs.hccActionToken, epoch: terminalWs.hccControl.epoch }));
2683
3038
 
@@ -2694,7 +3049,7 @@ async function webSecretRedactionWorkflow() {
2694
3049
 
2695
3050
  const invalidCookie = await fetch(new URL('/api/runtime', runtime.base_url), {
2696
3051
  headers: {
2697
- Cookie: `hcc_sid=${secret}`,
3052
+ Cookie: `${auth.name}=${secret}`,
2698
3053
  'X-HCC-API-Version': '2'
2699
3054
  }
2700
3055
  });
@@ -2743,6 +3098,7 @@ async function assertWebWrapperParentSurvives() {
2743
3098
  const wrapperRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-wrapper-root-${testId}-`));
2744
3099
  const wrapperPort = port + 101;
2745
3100
  let wrapperPid = null;
3101
+ let wrapperOwner = null;
2746
3102
  try {
2747
3103
  const script = [
2748
3104
  'set -e',
@@ -2757,44 +3113,62 @@ async function assertWebWrapperParentSurvives() {
2757
3113
  if (!match) fail(`wrapper hcc web did not print background pid:\n${result.stdout}`);
2758
3114
  wrapperPid = Number.parseInt(match[1], 10);
2759
3115
  ensureFile(path.join(wrapperRoot, '.hello-cc', 'runtime.json'));
3116
+ wrapperOwner = JSON.parse(fs.readFileSync(path.join(wrapperRoot, '.hello-cc', 'runtime.json'), 'utf8')).process_identity;
2760
3117
  } finally {
2761
- runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env });
3118
+ runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env: fixtureStopEnvironment(env) });
2762
3119
  if (wrapperPid) {
2763
3120
  try {
2764
- await waitForProcessExit(wrapperPid, 'wrapper runtime process exit', 5000);
3121
+ await waitForFixtureOwnerExit(wrapperOwner, 5000);
2765
3122
  } catch {
2766
- try { process.kill(wrapperPid, 'SIGTERM'); } catch {}
2767
- try { await waitForProcessExit(wrapperPid, 'wrapper runtime process exit after SIGTERM', 2000); } catch {}
3123
+ signalFixtureOwner(wrapperOwner, 'SIGTERM');
3124
+ try { await waitForFixtureOwnerExit(wrapperOwner, 2000); } catch {}
2768
3125
  }
2769
3126
  }
2770
3127
  try { fs.rmSync(wrapperRoot, { recursive: true, force: true }); } catch {}
2771
3128
  }
2772
3129
  }
2773
3130
 
2774
- async function stopRuntime() {
3131
+ async function stopRuntime({ peerStops = [] } = {}) {
2775
3132
  if (!runtimePid) return;
2776
- const pid = runtimePid;
2777
- hccMaybe(['down']);
3133
+ const owner = runtimeOwner;
3134
+ const before = observeFixtureOwner(owner);
3135
+ const downStarted = performance.now();
3136
+ const down = fixtureDownResult(stopFixtureCli());
3137
+ const downDurationMs = Math.round(performance.now() - downStarted);
3138
+ const afterDown = observeFixtureOwner(owner);
3139
+ lastStopDiagnostic = { before, down, downDurationMs, afterDown, peerStops, stack: new Error('stopRuntime').stack.split('\n').slice(1, 7), signals: [] };
2778
3140
  try {
2779
- await waitForProcessExit(pid, 'runtime process exit', 5000);
3141
+ await waitForFixtureOwnerExit(owner, 5000);
2780
3142
  } catch (err) {
2781
- try { process.kill(pid, 'SIGTERM'); } catch {}
3143
+ lastStopDiagnostic.atDeadline = collectRuntimeDiagnostic({ before, down });
3144
+ lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGTERM'));
2782
3145
  try {
2783
- await waitForProcessExit(pid, 'runtime process exit after SIGTERM', 2000);
3146
+ await waitForFixtureOwnerExit(owner, 2000);
2784
3147
  } catch {
2785
- try { process.kill(pid, 'SIGKILL'); } catch {}
2786
- try { await waitForProcessExit(pid, 'runtime process exit after SIGKILL', 2000); } catch {}
2787
- throw err;
3148
+ lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGKILL'));
3149
+ try { await waitForFixtureOwnerExit(owner, 2000); } catch {}
2788
3150
  }
3151
+ fail(`runtime stop failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
2789
3152
  }
3153
+ if (down.status !== 0) fail(`runtime down command failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
2790
3154
  runtimePid = null;
3155
+ runtimeOwner = null;
3156
+ }
3157
+
3158
+ function stopFixtureCli() {
3159
+ try {
3160
+ const pointer = currentRuntime();
3161
+ if (compareProcessIdentity(runtimeOwner, pointer.process_identity) === 'live' &&
3162
+ runtimeOwner.commandHash === pointer.process_identity.commandHash) {
3163
+ return hccMaybe(['--json', 'down'], { env: fixtureStopEnvironment(env) });
3164
+ }
3165
+ } catch {}
3166
+ return { status: null, error: { code: 'FIXTURE_OWNER_POINTER_UNAVAILABLE' } };
2791
3167
  }
2792
3168
 
2793
3169
  function cleanup() {
2794
- try { hccMaybe(['down']); } catch {}
2795
- if (runtimePid) {
2796
- try { process.kill(runtimePid, 'SIGTERM'); } catch {}
2797
- }
3170
+ try { stopFixtureCli(); } catch {}
3171
+ if (runtimePid) signalFixtureOwner(runtimeOwner, 'SIGTERM');
2798
3172
  const managedPrefixes = [
2799
3173
  tmuxManagedSessionPrefix(root),
2800
3174
  tmuxManagedSessionPrefix(secondProjectRoot)
@@ -2813,10 +3187,6 @@ function cleanup() {
2813
3187
  if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
2814
3188
  runMaybe('tmux', ['kill-server']);
2815
3189
  }
2816
- if (process.env.HCC_REGRESSION_PRESERVE_ARTIFACTS === '1') {
2817
- log(`regression artifacts preserved: ${root}`);
2818
- return;
2819
- }
2820
3190
  for (const dir of [root, home, fakeBin, outDir]) {
2821
3191
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
2822
3192
  }
@@ -2886,7 +3256,7 @@ async function setupRegression() {
2886
3256
  const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance']);
2887
3257
  const match = output.match(/^pid:\s*(\d+)/m);
2888
3258
  if (!match) fail(`hcc web did not print background pid during bootstrap:\n${output}`);
2889
- runtimePid = Number.parseInt(match[1], 10);
3259
+ rememberRuntimePid(Number.parseInt(match[1], 10));
2890
3260
  await waitRuntime();
2891
3261
  for (const file of liveBufferFiles) ensureFile(file);
2892
3262
  for (const file of liveBufferFiles) fs.rmSync(file, { force: true });
@@ -2966,7 +3336,7 @@ async function setupRegression() {
2966
3336
  const directTlsOutput = hcc(['web', '--local', '--tls', '--port', String(port), '--no-discover', '--no-guidance']);
2967
3337
  const directTlsMatch = directTlsOutput.match(/^pid:\s*(\d+)/m);
2968
3338
  if (!directTlsMatch) fail(`direct TLS web did not print background pid:\n${directTlsOutput}`);
2969
- runtimePid = Number.parseInt(directTlsMatch[1], 10);
3339
+ rememberRuntimePid(Number.parseInt(directTlsMatch[1], 10));
2970
3340
  await waitFor(async () => {
2971
3341
  const runtimeFile = path.join(root, '.hello-cc', 'runtime.json');
2972
3342
  if (!fs.existsSync(runtimeFile)) return false;
@@ -2995,14 +3365,14 @@ async function setupRegression() {
2995
3365
  { headers: { Accept: 'text/html' } }
2996
3366
  );
2997
3367
  const directTlsSetCookie = String(directTlsExchange.headers['set-cookie']?.[0] || '');
2998
- const directTlsSid = directTlsSetCookie.match(/hcc_sid=([^;]+)/)?.[1] || '';
2999
- if (directTlsExchange.status !== 302 || !directTlsSid || !directTlsSetCookie.includes('Secure')) {
3000
- fail(`direct TLS login did not issue a Secure cookie: status=${directTlsExchange.status} cookie=${directTlsSetCookie}`);
3368
+ if (directTlsExchange.status !== 302 || !directTlsSetCookie.includes('Secure')) {
3369
+ fail(`direct TLS login did not issue a Secure cookie: status=${directTlsExchange.status}`);
3001
3370
  }
3371
+ const directTlsAuth = browserSessionCookie(directTlsSetCookie, directTlsRuntime.base_url);
3002
3372
  const directTlsLogout = await directTlsRequest(directTlsRuntime, '/logout', {
3003
3373
  method: 'POST',
3004
3374
  headers: {
3005
- Cookie: `hcc_sid=${directTlsSid}`,
3375
+ Cookie: directTlsAuth.cookie,
3006
3376
  Origin: new URL(directTlsRuntime.base_url).origin
3007
3377
  }
3008
3378
  });
@@ -3010,8 +3380,9 @@ async function setupRegression() {
3010
3380
  if (directTlsLogout.status !== 204 ||
3011
3381
  !directTlsLogoutCookie.includes('Max-Age=0') ||
3012
3382
  !directTlsLogoutCookie.includes('Secure')) {
3013
- fail(`direct TLS logout did not expire a Secure cookie: status=${directTlsLogout.status} cookie=${directTlsLogoutCookie}`);
3383
+ fail(`direct TLS logout did not expire a Secure cookie: status=${directTlsLogout.status}`);
3014
3384
  }
3385
+ browserSessionCookie(directTlsLogoutCookie, directTlsRuntime.base_url, { expired: true });
3015
3386
  await stopRuntime();
3016
3387
 
3017
3388
  await assertWebWrapperParentSurvives();
@@ -3025,7 +3396,7 @@ async function setupRegression() {
3025
3396
  const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
3026
3397
  const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
3027
3398
  if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
3028
- runtimePid = Number.parseInt(tokenMatch[1], 10);
3399
+ rememberRuntimePid(Number.parseInt(tokenMatch[1], 10));
3029
3400
  if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
3030
3401
  fail(`default web output did not include remote token URL:\n${tokenOutput}`);
3031
3402
  }
@@ -3119,26 +3490,28 @@ async function setupRegression() {
3119
3490
  });
3120
3491
  if (exchange.status !== 302) fail(`browser-nav ?token did not exchange to a cookie redirect: ${exchange.status}`);
3121
3492
  const setCookie = exchange.headers.get('set-cookie') || '';
3122
- if (!setCookie.includes('hcc_sid=') || !setCookie.includes('HttpOnly') || !setCookie.includes('SameSite=Lax')) {
3123
- fail(`exchange did not set an HttpOnly SameSite cookie: ${setCookie}`);
3124
- }
3125
- if (setCookie.includes('Secure')) fail(`plaintext LAN exchange issued a Secure cookie: ${setCookie}`);
3126
- const sidMatch = setCookie.match(/hcc_sid=([^;]+)/);
3127
- if (!sidMatch) fail(`exchange did not return a session id: ${setCookie}`);
3128
- const sid = sidMatch[1];
3129
- // (b) API with the session cookie → 200
3130
- const withCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: `hcc_sid=${sid}`, 'X-HCC-API-Version': '2' } });
3493
+ if (!setCookie.includes('HttpOnly') || !setCookie.includes('SameSite=Lax')) {
3494
+ fail('exchange did not set an HttpOnly SameSite cookie');
3495
+ }
3496
+ if (setCookie.includes('Secure')) fail('plaintext LAN exchange issued a Secure cookie');
3497
+ const browserAuth = browserSessionCookie(setCookie, baseUrl);
3498
+ // (b) API with the session cookie and explicitly selected project → 200
3499
+ const cookieProjectIdentity = await selectCookieProject(baseUrl, browserAuth.cookie);
3500
+ const withCookie = await fetch(`${baseUrl}/api/runtime`, {
3501
+ headers: { Cookie: browserAuth.cookie, 'X-HCC-API-Version': '2', 'X-HCC-Root-Identity': cookieProjectIdentity }
3502
+ });
3131
3503
  if (!withCookie.ok) fail(`API with session cookie required auth: ${withCookie.status}`);
3132
3504
  // (c) API with a bogus session cookie → 401
3133
- const bogusCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: 'hcc_sid=bogus', 'X-HCC-API-Version': '2' } });
3505
+ const bogusCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: `${browserAuth.name}=bogus`, 'X-HCC-API-Version': '2' } });
3134
3506
  if (bogusCookie.status !== 401) fail(`API with bogus session cookie did not 401: ${bogusCookie.status}`);
3135
3507
  // (d) cookie-authenticated writes require an exact same-origin Origin header.
3136
3508
  const crossOriginCookieWrite = await fetch(`${baseUrl}/api/csrf-probe`, {
3137
3509
  method: 'POST',
3138
3510
  headers: {
3139
- Cookie: `hcc_sid=${sid}`,
3511
+ Cookie: browserAuth.cookie,
3140
3512
  Origin: `http://127.0.0.1:${port + 1}`,
3141
3513
  'X-HCC-API-Version': '2',
3514
+ 'X-HCC-Root-Identity': cookieProjectIdentity,
3142
3515
  'Content-Type': 'application/json'
3143
3516
  },
3144
3517
  body: '{}'
@@ -3153,9 +3526,10 @@ async function setupRegression() {
3153
3526
  const sameOriginCookieWrite = await fetch(`${baseUrl}/api/csrf-probe`, {
3154
3527
  method: 'POST',
3155
3528
  headers: {
3156
- Cookie: `hcc_sid=${sid}`,
3529
+ Cookie: browserAuth.cookie,
3157
3530
  Origin: new URL(baseUrl).origin,
3158
3531
  'X-HCC-API-Version': '2',
3532
+ 'X-HCC-Root-Identity': cookieProjectIdentity,
3159
3533
  'Content-Type': 'application/json'
3160
3534
  },
3161
3535
  body: '{}'
@@ -3180,23 +3554,25 @@ async function setupRegression() {
3180
3554
  body: JSON.stringify({ token: cookieToken }),
3181
3555
  redirect: 'manual'
3182
3556
  });
3183
- if (loginPost.status !== 302 || !(loginPost.headers.get('set-cookie') || '').includes('hcc_sid=')) {
3557
+ if (loginPost.status !== 302) {
3184
3558
  fail(`POST /login did not issue a session cookie: ${loginPost.status}`);
3185
3559
  }
3560
+ browserSessionCookie(loginPost.headers.get('set-cookie') || '', baseUrl);
3186
3561
 
3187
3562
  // (h) logout revokes the opaque session and expires the browser cookie.
3188
3563
  const logout = await fetch(`${baseUrl}/logout`, {
3189
3564
  method: 'POST',
3190
3565
  headers: {
3191
- Cookie: `hcc_sid=${sid}`,
3566
+ Cookie: browserAuth.cookie,
3192
3567
  Origin: new URL(baseUrl).origin
3193
3568
  }
3194
3569
  });
3195
3570
  const logoutCookie = logout.headers.get('set-cookie') || '';
3196
- if (logout.status !== 204 || !logoutCookie.includes('hcc_sid=') || !logoutCookie.includes('Max-Age=0')) {
3197
- fail(`logout did not revoke and expire the session cookie: status=${logout.status} cookie=${logoutCookie}`);
3571
+ if (logout.status !== 204 || !logoutCookie.includes('Max-Age=0')) {
3572
+ fail(`logout did not revoke and expire the session cookie: status=${logout.status}`);
3198
3573
  }
3199
- const revokedCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: `hcc_sid=${sid}`, 'X-HCC-API-Version': '2' } });
3574
+ browserSessionCookie(logoutCookie, baseUrl, { expired: true });
3575
+ const revokedCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: browserAuth.cookie, 'X-HCC-API-Version': '2' } });
3200
3576
  if (revokedCookie.status !== 401) fail(`logout left the old session cookie authorized: ${revokedCookie.status}`);
3201
3577
 
3202
3578
  // (i) trusted loopback reverse-proxy headers mark issued and expired cookies
@@ -3209,7 +3585,7 @@ async function setupRegression() {
3209
3585
  ]);
3210
3586
  const proxyPidMatch = proxyOutput.match(/^pid:\s*(\d+)/m);
3211
3587
  if (!proxyPidMatch) fail(`trusted-proxy web did not print background pid:\n${proxyOutput}`);
3212
- runtimePid = Number.parseInt(proxyPidMatch[1], 10);
3588
+ rememberRuntimePid(Number.parseInt(proxyPidMatch[1], 10));
3213
3589
  await waitRuntime();
3214
3590
  const proxyRuntime = currentRuntime();
3215
3591
  if (proxyRuntime.proxy_origin !== proxyOrigin || proxyRuntime.trust_proxy !== true) {
@@ -3225,14 +3601,24 @@ async function setupRegression() {
3225
3601
  redirect: 'manual'
3226
3602
  });
3227
3603
  const proxySetCookie = proxyExchange.headers.get('set-cookie') || '';
3228
- const proxySid = proxySetCookie.match(/hcc_sid=([^;]+)/)?.[1] || '';
3229
- if (proxyExchange.status !== 302 || !proxySid || !proxySetCookie.includes('Secure')) {
3230
- fail(`trusted proxy login did not issue a Secure cookie: status=${proxyExchange.status} cookie=${proxySetCookie}`);
3604
+ if (proxyExchange.status !== 302 || !proxySetCookie.includes('Secure')) {
3605
+ fail(`trusted proxy login did not issue a Secure cookie: status=${proxyExchange.status}`);
3231
3606
  }
3607
+ const proxyAuth = browserSessionCookie(proxySetCookie, proxyOrigin);
3608
+ const proxyProjectIdentity = await selectCookieProject(baseUrl, proxyAuth.cookie, root, {
3609
+ origin: proxyOrigin, headers: proxyHeaders
3610
+ });
3611
+ const withProxyCookie = await fetch(`${baseUrl}/api/runtime`, {
3612
+ headers: {
3613
+ ...proxyHeaders, Cookie: proxyAuth.cookie, 'X-HCC-API-Version': '2',
3614
+ 'X-HCC-Root-Identity': proxyProjectIdentity
3615
+ }
3616
+ });
3617
+ if (!withProxyCookie.ok) fail(`trusted proxy API rejected its session cookie: ${withProxyCookie.status}`);
3232
3618
  const proxyLogout = await fetch(`${baseUrl}/logout`, {
3233
3619
  method: 'POST',
3234
3620
  headers: {
3235
- Cookie: `hcc_sid=${proxySid}`,
3621
+ Cookie: proxyAuth.cookie,
3236
3622
  Origin: proxyOrigin,
3237
3623
  'X-Forwarded-Host': 'public.example.test:9443',
3238
3624
  'X-Forwarded-Proto': 'https'
@@ -3240,9 +3626,12 @@ async function setupRegression() {
3240
3626
  });
3241
3627
  const proxyLogoutCookie = proxyLogout.headers.get('set-cookie') || '';
3242
3628
  if (proxyLogout.status !== 204 || !proxyLogoutCookie.includes('Max-Age=0') || !proxyLogoutCookie.includes('Secure')) {
3243
- fail(`trusted proxy logout did not expire a Secure cookie: status=${proxyLogout.status} cookie=${proxyLogoutCookie}`);
3629
+ fail(`trusted proxy logout did not expire a Secure cookie: status=${proxyLogout.status}`);
3244
3630
  }
3245
- const revokedProxyCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: `hcc_sid=${proxySid}`, 'X-HCC-API-Version': '2' } });
3631
+ browserSessionCookie(proxyLogoutCookie, proxyOrigin, { expired: true });
3632
+ const revokedProxyCookie = await fetch(`${baseUrl}/api/runtime`, {
3633
+ headers: { ...proxyHeaders, Cookie: proxyAuth.cookie, 'X-HCC-API-Version': '2' }
3634
+ });
3246
3635
  if (revokedProxyCookie.status !== 401) fail(`trusted proxy logout left the old cookie authorized: ${revokedProxyCookie.status}`);
3247
3636
 
3248
3637
  // A forwarded authority other than the pinned public origin is untrusted.
@@ -3257,7 +3646,7 @@ async function setupRegression() {
3257
3646
  });
3258
3647
  const defaultPortProxySetCookie = defaultPortProxyExchange.headers.get('set-cookie') || '';
3259
3648
  if (defaultPortProxyExchange.status !== 403 || defaultPortProxySetCookie) {
3260
- fail(`unpinned proxy authority received a session: status=${defaultPortProxyExchange.status} cookie=${defaultPortProxySetCookie}`);
3649
+ fail(`unpinned proxy authority received a session: status=${defaultPortProxyExchange.status} setCookie=${Boolean(defaultPortProxySetCookie)}`);
3261
3650
  }
3262
3651
  const unpinnedProxyLogin = await fetch(`${baseUrl}/login`, {
3263
3652
  method: 'POST',
@@ -3282,7 +3671,7 @@ async function setupRegression() {
3282
3671
  });
3283
3672
  const spoofedLanCookie = spoofedLanExchange.headers.get('set-cookie') || '';
3284
3673
  if (spoofedLanExchange.status !== 403 || spoofedLanCookie) {
3285
- fail(`non-loopback forwarded spoof received a session: status=${spoofedLanExchange.status} cookie=${spoofedLanCookie}`);
3674
+ fail(`non-loopback forwarded spoof received a session: status=${spoofedLanExchange.status} setCookie=${Boolean(spoofedLanCookie)}`);
3286
3675
  }
3287
3676
  } else {
3288
3677
  log('non-loopback forwarded spoof: no non-loopback IPv4 interface; focused boundary coverage retained');
@@ -3295,7 +3684,7 @@ async function setupRegression() {
3295
3684
  ]);
3296
3685
  const restoredPidMatch = restoredOutput.match(/^pid:\s*(\d+)/m);
3297
3686
  if (!restoredPidMatch) fail(`post-proxy web did not print background pid:\n${restoredOutput}`);
3298
- runtimePid = Number.parseInt(restoredPidMatch[1], 10);
3687
+ rememberRuntimePid(Number.parseInt(restoredPidMatch[1], 10));
3299
3688
  await waitRuntime();
3300
3689
 
3301
3690
  const badJsonResponse = await runtimeFetch('/api/projects', {
@@ -3322,7 +3711,7 @@ async function setupRegression() {
3322
3711
  const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
3323
3712
  const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
3324
3713
  if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
3325
- runtimePid = Number.parseInt(stableTokenMatch[1], 10);
3714
+ rememberRuntimePid(Number.parseInt(stableTokenMatch[1], 10));
3326
3715
  await waitRuntime();
3327
3716
  const stableTokenRuntime = currentRuntime();
3328
3717
  if (stableTokenRuntime.token === tokenRuntime.token) {
@@ -3336,7 +3725,7 @@ async function setupRegression() {
3336
3725
  });
3337
3726
  const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
3338
3727
  if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
3339
- runtimePid = Number.parseInt(fixedTokenMatch[1], 10);
3728
+ rememberRuntimePid(Number.parseInt(fixedTokenMatch[1], 10));
3340
3729
  await waitRuntime();
3341
3730
  const fixedTokenRuntime = currentRuntime();
3342
3731
  if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
@@ -3347,7 +3736,7 @@ async function setupRegression() {
3347
3736
  const postFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
3348
3737
  const postFixedMatch = postFixedOutput.match(/^pid:\s*(\d+)/m);
3349
3738
  if (!postFixedMatch) fail(`post-fixed-token web did not print background pid:\n${postFixedOutput}`);
3350
- runtimePid = Number.parseInt(postFixedMatch[1], 10);
3739
+ rememberRuntimePid(Number.parseInt(postFixedMatch[1], 10));
3351
3740
  await waitRuntime();
3352
3741
  const postFixedRuntime = currentRuntime();
3353
3742
  if (postFixedRuntime.token === fixedToken || fs.existsSync(tokenFile)) {
@@ -3360,7 +3749,7 @@ async function setupRegression() {
3360
3749
  const noTokenOutput = hcc(['web', '--local', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
3361
3750
  const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
3362
3751
  if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
3363
- runtimePid = Number.parseInt(noTokenMatch[1], 10);
3752
+ rememberRuntimePid(Number.parseInt(noTokenMatch[1], 10));
3364
3753
  if (noTokenOutput.includes('token=')) {
3365
3754
  fail(`explicit no-token web output included token:\n${noTokenOutput}`);
3366
3755
  }
@@ -4427,13 +4816,21 @@ async function multiProjectWebWorkflow() {
4427
4816
  fail(`detected API did not return liveness metadata:\n${JSON.stringify(detectedJson, null, 2)}`);
4428
4817
  }
4429
4818
 
4430
- const htmlResponse = await fetch(currentRuntimeUrl('/'));
4431
- const html = await htmlResponse.text();
4819
+ const htmlResponse = await withFixtureHttpDiagnostic('/', {}, () => fetch(currentRuntimeUrl('/')));
4820
+ const pageHtml = await htmlResponse.text();
4821
+ if (!pageHtml.includes('type="module" src="/assets/web/browser/core.mjs"')) fail('web page missing ESM bootstrap');
4822
+ const moduleSources = [];
4823
+ for (const asset of ['browser/core.mjs', 'ui-agent-start.mjs']) {
4824
+ const response = await runtimeFetch('/assets/web/' + asset);
4825
+ if (!response.ok || !response.headers.get('content-type')?.includes('javascript')) fail('web ESM asset did not load: ' + asset);
4826
+ moduleSources.push(await response.text());
4827
+ }
4828
+ const html = pageHtml + '\n' + moduleSources.join('\n');
4432
4829
  const startFormHtml = html.match(/<form\b[^>]*\bid="startForm"[^>]*>[\s\S]*?<\/form>/)?.[0];
4433
4830
  if (!startFormHtml) fail('web form missing simplified session form');
4434
4831
  // A current-directory label in an approval or session details is valid;
4435
4832
  // obsolete creation inputs must be checked inside the creation form.
4436
- for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory']) {
4833
+ for (const forbidden of ['Alias optional', 'Role tag', 'Command<input']) {
4437
4834
  if (startFormHtml.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
4438
4835
  }
4439
4836
  for (const forbidden of ['commandbar', 'lineInput', 'Send text to active terminal']) {
@@ -4444,6 +4841,7 @@ async function multiProjectWebWorkflow() {
4444
4841
  'id="projectPath"',
4445
4842
  'id="addProjectBtn"',
4446
4843
  'id="startForm"',
4844
+ 'id="agentCwd"',
4447
4845
  'id="kind"',
4448
4846
  'id="sessionKindFilter"',
4449
4847
  'id="sessions"'
@@ -4487,7 +4885,7 @@ async function multiProjectWebWorkflow() {
4487
4885
  !html.includes("fetch('/logout', { method: 'POST', headers })")) {
4488
4886
  fail('web UI missing logout control or session revocation request');
4489
4887
  }
4490
- if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode === 'resume'")) {
4888
+ if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode.value === 'resume'")) {
4491
4889
  fail('web form missing provider resume controls');
4492
4890
  }
4493
4891
  for (const expected of [
@@ -4742,7 +5140,7 @@ async function multiProjectWebWorkflow() {
4742
5140
  fail(`same-origin cookie administrator create failed or leaked its action token:\n${JSON.stringify(cookieCreated, null, 2)}`);
4743
5141
  }
4744
5142
 
4745
- const cookieAdminWs = await openCookieTerminalWebSocket(cookieAdminId, cookieAdmin.sid, { root });
5143
+ const cookieAdminWs = await openCookieTerminalWebSocket(cookieAdminId, cookieAdmin.cookie, { root }, cookieAdmin.projectIdentity);
4746
5144
  try {
4747
5145
  if (!cookieAdminWs.hccActionToken) fail('cookie administrator terminal snapshot omitted its action token');
4748
5146
  const inputRoute = `/api/sessions/${encodeURIComponent(cookieAdminId)}/input`;
@@ -5782,6 +6180,7 @@ async function bufferGcArbitrationWorkflow() {
5782
6180
  } finally {
5783
6181
  unifiedDb.close();
5784
6182
  }
6183
+ const gapPreviewStartedAtSec = Math.floor(Date.now() / 1000);
5785
6184
  setSiblingClockGap(Math.floor(fs.statSync(gapOrphan).mtimeMs / 1000));
5786
6185
  const beforeGapPreviewDb = new DatabaseSync(path.join(secondProjectRoot, '.hello-cc', 'mesh.db'), { timeout: 5000 });
5787
6186
  let beforeGapMeta;
@@ -5794,8 +6193,11 @@ async function bufferGcArbitrationWorkflow() {
5794
6193
  const afterGapPreviewDb = new DatabaseSync(path.join(secondProjectRoot, '.hello-cc', 'mesh.db'), { timeout: 5000 });
5795
6194
  try {
5796
6195
  const afterGapMeta = afterGapPreviewDb.prepare('SELECT key, value FROM meta ORDER BY key').all();
5797
- if (JSON.stringify(afterGapMeta) !== JSON.stringify(beforeGapMeta) ||
5798
- !afterGapPreviewDb.prepare('SELECT 1 FROM events WHERE id = ?').get(unifiedIds.event) ||
6196
+ assertGcPreviewMetadata(beforeGapMeta, afterGapMeta, {
6197
+ startedAtSec: gapPreviewStartedAtSec,
6198
+ finishedAtSec: Math.floor(Date.now() / 1000)
6199
+ });
6200
+ if (!afterGapPreviewDb.prepare('SELECT 1 FROM events WHERE id = ?').get(unifiedIds.event) ||
5799
6201
  !afterGapPreviewDb.prepare("SELECT 1 FROM locks WHERE resource = 'gc-unified-lock'").get() ||
5800
6202
  !afterGapPreviewDb.prepare("SELECT 1 FROM peers WHERE id = 'gc-unified-dead'").get()) {
5801
6203
  fail(`manual GC dry-run changed unified clock state: ${JSON.stringify({ beforeGapMeta, afterGapMeta })}`);
@@ -6258,13 +6660,53 @@ async function shimTmuxWorkflow() {
6258
6660
  cwd: root,
6259
6661
  env: { ...shimEnv, HCC_REG_VALUE: 'shim-second' }
6260
6662
  });
6261
- parsePane(restarted);
6663
+ const restartedPane = parsePane(restarted);
6262
6664
  const secondEnvFile = path.join(outDir, 'shim-env-second');
6263
6665
  hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${secondEnvFile}`]);
6264
6666
  await waitForFile(secondEnvFile, 'shim-second', 'shim env restart');
6265
6667
  const reentryFile = path.join(outDir, 'shim-reentry');
6266
- hcc(['inject', peer, `HCC_FAKE_STAY_ALIVE=0 HCC_REG_VALUE=shim-third ${sh(shim)} --resume shim-regression-session > ${sh(reentryFile)} 2>&1`]);
6267
- await waitForFileContent(reentryFile, 'fake-claude --resume shim-regression-session', 'shim tmux pane re-entry');
6668
+ const reentryTrace = path.join(outDir, 'shim-reentry-stages');
6669
+ const reentryStatus = path.join(outDir, 'shim-reentry-status');
6670
+ const reentryProvider = path.join(outDir, 'shim-reentry-provider');
6671
+ const reentryProbe = pathToFileURL(path.join(repoRoot, 'scripts', 'shim-reentry-probe.mjs')).href;
6672
+ const reentryEventId = withMeshDb(db => db.prepare('SELECT MAX(id) AS id FROM events').get().id || 0);
6673
+ const reentryStarted = Date.now();
6674
+ hcc(['inject', peer, `printf 'started\\n' > ${sh(reentryStatus)}; HCC_FAKE_STAY_ALIVE=0 HCC_REG_VALUE=shim-third HCC_FAKE_LOG=${sh(reentryProvider)} HCC_REGRESSION_REENTRY_TRACE=${sh(reentryTrace)} HCC_REGRESSION_REENTRY_BIN=${sh(hccBin)} NODE_OPTIONS="\${NODE_OPTIONS:+\${NODE_OPTIONS} }"${sh(`--import=${reentryProbe}`)} ${sh(shim)} --resume shim-regression-session > ${sh(reentryFile)} 2>&1; printf '%s\\n' "$?" >> ${sh(reentryStatus)}`]);
6675
+ await waitForFileContent(reentryFile, 'fake-claude --resume shim-regression-session', 'shim tmux pane re-entry', {
6676
+ ownedReentryOutput: true,
6677
+ diagnostics: () => {
6678
+ // Query only this fixture's pane and fixed event types. Full terminal
6679
+ // captures, process arguments, request bodies and environment are excluded.
6680
+ const paneResult = spawnSync('tmux', ['display-message', '-p', '-t', restartedPane,
6681
+ '#{pane_id}|#{pane_dead}|#{pane_current_command}'], {
6682
+ env, encoding: 'utf8', timeout: 1000, maxBuffer: 1024, stdio: ['ignore', 'pipe', 'ignore']
6683
+ });
6684
+ const paneParts = (paneResult.stdout || '').trim().split('|');
6685
+ const pane = {
6686
+ found: paneResult.status === 0 && /^%\d+$/.test(paneParts[0] || ''),
6687
+ dead: paneParts[1] === '1',
6688
+ command: /^[A-Za-z0-9_.-]{1,40}$/.test(paneParts[2] || '') ? paneParts[2] : 'unknown'
6689
+ };
6690
+ let status = [];
6691
+ try { status = fs.readFileSync(reentryStatus, 'utf8').slice(0, 32).trim().split('\n'); } catch {}
6692
+ let events = [];
6693
+ let db;
6694
+ try {
6695
+ db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { timeout: 250, readOnly: true });
6696
+ events = db.prepare(`SELECT type, created_at FROM events WHERE id > ? AND actor = ?
6697
+ AND type IN ('peer.attach.requested', 'tmux.session.attached', 'tmux.session.detached', 'tmux.session.exited')
6698
+ ORDER BY id DESC LIMIT 8`).all(reentryEventId, peer);
6699
+ } catch { events = [{ state: 'unavailable' }]; }
6700
+ finally { if (db) db.close(); }
6701
+ return {
6702
+ elapsed_ms: Date.now() - reentryStarted,
6703
+ shell_started: status[0] === 'started',
6704
+ shell_exit: /^\d{1,3}$/.test(status[1] || '') ? Number(status[1]) : null,
6705
+ provider_entered: fs.existsSync(reentryProvider),
6706
+ pane, stages: readReentryTrace(reentryTrace), events
6707
+ };
6708
+ }
6709
+ });
6268
6710
  hcc(['peer', 'stop', peer]);
6269
6711
 
6270
6712
  const exitedResume = 'shim-exited-session';
@@ -6384,15 +6826,18 @@ async function askBroadcastWorkflow() {
6384
6826
  }
6385
6827
  await waitForFile(dispatchFile, 'DISPATCH_OK', 'task dispatch injection');
6386
6828
  const broadcastFile = path.join(outDir, 'broadcast-ok');
6387
- hcc(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject']);
6829
+ hccJson(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject'], { diagnosticOperation: 'broadcast' });
6388
6830
  await waitForFile(broadcastFile, 'BROADCAST_OK', 'broadcast injection');
6389
6831
  }
6390
6832
 
6391
6833
  async function downGcPackWorkflow() {
6392
6834
  log('[9/13] down/gc/pack');
6393
- hccMaybe(['peer', 'stop', 'shell-a']);
6394
- hccMaybe(['peer', 'stop', 'shell-b']);
6395
- await stopRuntime();
6835
+ const peerStops = ['shell-a', 'shell-b'].map(peer => {
6836
+ const started = performance.now();
6837
+ const result = fixtureDownResult(hccMaybe(['--json', 'peer', 'stop', peer]));
6838
+ return { ...result, durationMs: Math.round(performance.now() - started) };
6839
+ });
6840
+ await stopRuntime({ peerStops });
6396
6841
  await waitFor(() => !fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), 'runtime cleanup', 5000);
6397
6842
  hcc(['gc', '--older-than', '0', '--yes']);
6398
6843
  const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
@@ -6641,7 +7086,7 @@ async function syntaxAndHelp() {
6641
7086
  const integrationShimScriptSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'shims', 'script.mjs'), 'utf8');
6642
7087
  const shellPathSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shell-path.mjs'), 'utf8');
6643
7088
  const webPeerActionsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'peer-actions.mjs'), 'utf8');
6644
- const webUiTemplateSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'ui-template.mjs'), 'utf8');
7089
+ const webUiTemplateSource = ['ui-template.mjs', 'browser/core.mjs'].map(name => fs.readFileSync(path.join(repoRoot, 'lib', 'web', name), 'utf8')).join('\n');
6645
7090
  const tmuxSafetySource = fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'tmux-safety.mjs'), 'utf8');
6646
7091
  // cmdWeb moved whole to lib/web/runtime-main.mjs; subsystem modules extend it
6647
7092
  const cmdWebSource = [
@@ -6705,15 +7150,15 @@ async function syntaxAndHelp() {
6705
7150
  'sameResolvedPath(global.root, ctx.root)',
6706
7151
  'sameResolvedPath(global.db, ctx.dbPath)',
6707
7152
  'async function stopOrphanWebRuntimes(',
6708
- 'await stopOrphanWebRuntimes(ctx, existing.pid || null);',
6709
- 'await stopOrphanWebRuntimes(ctx);'
7153
+ 'await stopOrphanWebRuntimes(boundCtx, existing.pid || null);',
7154
+ 'await stopOrphanWebRuntimes(boundCtx);'
6710
7155
  ]) {
6711
7156
  if (!webStartupSource.includes(expected)) fail(`web startup guard missing: ${expected}`);
6712
7157
  }
6713
7158
  for (const expected of [
6714
7159
  'function listTmuxPanesOnce()',
6715
7160
  'let autoAttachScanInFlight = false;',
6716
- 'if (autoAttachScanInFlight) return;',
7161
+ 'if (isStopping() || autoAttachScanInFlight) return;',
6717
7162
  'const paneByPid = new Map();',
6718
7163
  'const attached = attachedTmuxState(ctx, db);',
6719
7164
  'function killOldTmuxForRebind(',
@@ -7073,7 +7518,7 @@ async function syntaxAndHelp() {
7073
7518
  !hccSource.includes("} from '../web/http.mjs'") ||
7074
7519
  !hccSource.includes("import * as webUiTemplate from '../web/ui-template.mjs'") ||
7075
7520
  !hccSource.includes('const VERSION = PACKAGE_META.version') ||
7076
- !hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
7521
+ !hccSource.includes('writeGuidanceForRoot(ctx.root, { expectedSnapshot: ctx.initialRootIdentity })')) {
7077
7522
  fail('CLI still has duplicated package metadata, cli args, DB schema helpers, CLI runtime helpers, coordination state helpers, format helpers, runtime paths/state helpers, runtime client helpers, project context helpers, handoff helpers, timeline helpers, task liveness helpers, automation helpers, state render helpers, help text helpers, message store helpers, task store helpers, task CLI helpers, session launch helpers, provider command helpers, peer binding helpers, tmux helpers, lock helpers, team planning helpers, peer identity helpers, project registry helpers, web runtime/HTTP/UI helpers, or guidance wiring');
7078
7523
  }
7079
7524
  for (const expected of [
@@ -10657,6 +11102,13 @@ function manualGcRetentionContractWorkflow() {
10657
11102
  }
10658
11103
  if (fs.existsSync(ordinaryBuffer)) fail('ordinary GC did not remove eligible technical buffer state');
10659
11104
 
11105
+ const beforeDryRun = new DatabaseSync(gcDbPath, { timeout: 5000 });
11106
+ let beforeDryRunMeta;
11107
+ try {
11108
+ beforeDryRunMeta = beforeDryRun.prepare('SELECT key, value FROM meta ORDER BY key').all();
11109
+ } finally {
11110
+ beforeDryRun.close();
11111
+ }
10660
11112
  const historyDryRun = readJson(['gc', '--older-than', '0', '--history']);
10661
11113
  for (const key of ['old_events', 'old_tasks', 'old_messages', 'old_handoffs']) {
10662
11114
  if (Number(historyDryRun[key] || 0) < 1) {
@@ -10668,6 +11120,10 @@ function manualGcRetentionContractWorkflow() {
10668
11120
  }
10669
11121
  const afterDryRun = new DatabaseSync(gcDbPath, { timeout: 5000 });
10670
11122
  try {
11123
+ const afterDryRunMeta = afterDryRun.prepare('SELECT key, value FROM meta ORDER BY key').all();
11124
+ if (JSON.stringify(afterDryRunMeta) !== JSON.stringify(beforeDryRunMeta)) {
11125
+ fail(`CLI-only history dry-run changed persisted metadata: ${JSON.stringify({ beforeDryRunMeta, afterDryRunMeta })}`);
11126
+ }
10671
11127
  if (!rowExists(afterDryRun, 'messages', seeded.message) ||
10672
11128
  !rowExists(afterDryRun, 'handoffs', seeded.handoff)) {
10673
11129
  fail('history dry-run changed persisted history');
@@ -11168,11 +11624,16 @@ function gcCoverageWorkflow() {
11168
11624
  }
11169
11625
 
11170
11626
  async function main() {
11627
+ realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
11628
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
11629
+ }).stdout.trim();
11171
11630
  process.once('SIGINT', () => { cleanup(); process.exit(130); });
11172
11631
  process.once('SIGTERM', () => { cleanup(); process.exit(143); });
11173
11632
 
11174
11633
  await setupRegression();
11634
+ currentStage = 'webSecretRedactionWorkflow';
11175
11635
  await webSecretRedactionWorkflow();
11636
+ currentStage = 'cookieSessionExpiryWorkflow';
11176
11637
  await cookieSessionExpiryWorkflow();
11177
11638
  log('[2/13] runtime');
11178
11639
  startRuntime();
@@ -11204,12 +11665,18 @@ async function main() {
11204
11665
  log('FULL_REGRESSION_OK');
11205
11666
  }
11206
11667
 
11207
- main().catch((err) => {
11668
+ if (isRegressionEntry()) main().catch((err) => {
11669
+ const failure = fixtureFailureDiagnostic(err);
11670
+ const diagnostic = collectRuntimeDiagnostic({ failure, stop: lastStopDiagnostic });
11671
+ process.stderr.write(`[regression failure diagnostic] ${diagnostic}\n`);
11672
+ if (process.env.GITHUB_ACTIONS === 'true') {
11673
+ process.stderr.write(`::error::${diagnostic.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}\n`);
11674
+ }
11208
11675
  try { assertNoRealProjectRegistryLeak(); } catch (leakErr) {
11209
- process.stderr.write(`${leakErr.stack || leakErr.message}\n`);
11676
+ process.stderr.write(`${fixtureDiagnosticText(leakErr.stack || leakErr.message, diagnosticSecrets)}\n`);
11210
11677
  }
11211
11678
  cleanup();
11212
- process.stderr.write(`${err.stack || err.message}\n`);
11679
+ process.stderr.write(`${fixtureDiagnosticText(err.stack || err.message, diagnosticSecrets)}\n`);
11213
11680
  process.exit(1);
11214
11681
  }).finally(() => {
11215
11682
  try {