@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.
- package/CHANGELOG.md +229 -108
- package/README.md +1 -10
- package/README.zh-CN.md +1 -8
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +13 -28
- package/docs/commands.zh-CN.md +11 -20
- package/docs/dsh.md +5 -3
- package/docs/dsh.zh-CN.md +6 -4
- package/docs/macos-process-identity.md +15 -0
- package/docs/native.md +176 -6
- package/docs/native.zh-CN.md +138 -3
- package/docs/web-handoff.zh-CN.md +107 -5
- package/lib/cli/app.mjs +9 -6
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/gc.mjs +14 -12
- package/lib/cli/commands/hook.mjs +16 -3
- package/lib/cli/commands/install.mjs +37 -9
- package/lib/cli/commands/lock.mjs +3 -1
- package/lib/cli/commands/misc.mjs +105 -13
- package/lib/cli/commands/native.mjs +41 -48
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/gc-plan.mjs +5 -0
- package/lib/core/coordination/locks.mjs +3 -1
- package/lib/core/peers/evidence.mjs +21 -1
- package/lib/core/peers/tmux-safety.mjs +4 -0
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +67 -21
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +20 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/dsh-collaboration.mjs +3 -4
- package/lib/integrations/dsh.mjs +4 -2
- package/lib/integrations/native/claude.mjs +53 -9
- package/lib/integrations/native/codex.mjs +75 -15
- package/lib/integrations/native/dsh-acp.mjs +77 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +16 -3
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +67 -28
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +7 -1
- package/lib/runtime/client.mjs +18 -1
- package/lib/runtime/native/client.mjs +12 -2
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +346 -52
- package/lib/runtime/native/store.mjs +75 -21
- package/lib/runtime/project-path.mjs +24 -5
- package/lib/runtime/projects.mjs +3 -0
- package/lib/runtime/state.mjs +61 -26
- package/lib/shared/file-lock.mjs +62 -17
- package/lib/shared/redact.mjs +2 -1
- package/lib/shared/socket-lock-worker.mjs +15 -6
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +2005 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +104 -33
- package/lib/web/codex-sessions.mjs +60 -11
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +22 -4
- package/lib/web/http-routes.mjs +237 -24
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +370 -21
- package/lib/web/project-contexts.mjs +235 -42
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +134 -82
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +76 -27
- package/lib/web/tmux-client-snapshot.mjs +85 -0
- package/lib/web/tmux-sessions.mjs +71 -7
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +81 -73
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +286 -8
- package/lib/web/ui-interactions.mjs +6 -4
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +115 -51
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1969
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +9 -2
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/helpers/gc-clock-observation.mjs +33 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +284 -25
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +655 -188
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +22 -7
- package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +152 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-native-retry-checks.mjs +177 -0
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +438 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
package/scripts/regression.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1792
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
-
},
|
|
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
|
-
|
|
1904
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
2596
|
-
|
|
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',
|
|
2602
|
-
clearTimeout(timer);
|
|
2603
|
-
reject(err);
|
|
2604
|
-
});
|
|
2942
|
+
ws.once('error', error => resolve({ error }));
|
|
2605
2943
|
});
|
|
2606
2944
|
|
|
2607
|
-
|
|
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
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2666
|
-
|
|
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,
|
|
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:
|
|
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
|
|
3121
|
+
await waitForFixtureOwnerExit(wrapperOwner, 5000);
|
|
2765
3122
|
} catch {
|
|
2766
|
-
|
|
2767
|
-
try { await
|
|
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
|
|
2777
|
-
|
|
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
|
|
3141
|
+
await waitForFixtureOwnerExit(owner, 5000);
|
|
2780
3142
|
} catch (err) {
|
|
2781
|
-
|
|
3143
|
+
lastStopDiagnostic.atDeadline = collectRuntimeDiagnostic({ before, down });
|
|
3144
|
+
lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGTERM'));
|
|
2782
3145
|
try {
|
|
2783
|
-
await
|
|
3146
|
+
await waitForFixtureOwnerExit(owner, 2000);
|
|
2784
3147
|
} catch {
|
|
2785
|
-
|
|
2786
|
-
try { await
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2999
|
-
|
|
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:
|
|
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}
|
|
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
|
-
|
|
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('
|
|
3123
|
-
fail(
|
|
3124
|
-
}
|
|
3125
|
-
if (setCookie.includes('Secure')) fail(
|
|
3126
|
-
const
|
|
3127
|
-
|
|
3128
|
-
const
|
|
3129
|
-
|
|
3130
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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('
|
|
3197
|
-
fail(`logout did not revoke and expire the session cookie: status=${logout.status}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3229
|
-
|
|
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:
|
|
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}
|
|
3629
|
+
fail(`trusted proxy logout did not expire a Secure cookie: status=${proxyLogout.status}`);
|
|
3244
3630
|
}
|
|
3245
|
-
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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'
|
|
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.
|
|
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
|
-
|
|
5798
|
-
|
|
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
|
-
|
|
6267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
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',
|
|
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(
|
|
6709
|
-
'await stopOrphanWebRuntimes(
|
|
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 {
|