@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- 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/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- 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 +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- 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 +1997 -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 +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- 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 +132 -80
- 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 +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- 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 +105 -85
- 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 +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- 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 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- 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/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- 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 +481 -219
- 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 +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
package/scripts/regression.mjs
CHANGED
|
@@ -17,6 +17,11 @@ import WebSocket from 'ws';
|
|
|
17
17
|
import { inspectProcessIdentity, compareProcessIdentity } from '../lib/process/identity.mjs';
|
|
18
18
|
import { redactSecrets } from '../lib/shared/redact.mjs';
|
|
19
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';
|
|
20
25
|
|
|
21
26
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
22
27
|
const hccBin = path.join(repoRoot, 'bin', 'hcc.mjs');
|
|
@@ -29,6 +34,10 @@ const tmuxSession = `hcc-reg-${process.pid}`;
|
|
|
29
34
|
const tmuxSocketName = `hcc-reg-${testId}`.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
30
35
|
const secondProjectRoot = path.join(root, 'second-project');
|
|
31
36
|
const TERMINAL_WEBSOCKET_TIMEOUT_MS = 15_000;
|
|
37
|
+
const COOKIE_WEBSOCKET_SNAPSHOT_TIMEOUT_MS = 5_000;
|
|
38
|
+
// now() uses whole seconds: TTL=1 may leave only 1ms before expiry. Reserve
|
|
39
|
+
// the existing snapshot budget plus clock quantization/setup margin first.
|
|
40
|
+
const COOKIE_EXPIRY_FIXTURE_TTL_SEC = Math.ceil(COOKIE_WEBSOCKET_SNAPSHOT_TIMEOUT_MS / 1000) + 2;
|
|
32
41
|
const realHome = process.env.HOME || os.homedir();
|
|
33
42
|
const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
|
|
34
43
|
let realTmuxBin = '';
|
|
@@ -41,10 +50,13 @@ let currentStage = 'startup';
|
|
|
41
50
|
let lastStopDiagnostic = null;
|
|
42
51
|
const diagnosticSecrets = new Set();
|
|
43
52
|
const fixtureFailures = new WeakSet();
|
|
53
|
+
const fixtureCommandFailures = new WeakMap();
|
|
54
|
+
const fixtureHttpFailures = new WeakMap();
|
|
44
55
|
const managedTmuxSessions = new Set();
|
|
45
56
|
|
|
46
57
|
const env = {
|
|
47
58
|
...process.env,
|
|
59
|
+
NODE_NO_WARNINGS: '1',
|
|
48
60
|
HOME: home,
|
|
49
61
|
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
|
|
50
62
|
SHELL: '/bin/bash'
|
|
@@ -53,6 +65,7 @@ for (const key of Object.keys(env)) {
|
|
|
53
65
|
if (key.startsWith('HCC_')) delete env[key];
|
|
54
66
|
}
|
|
55
67
|
if (process.env.HCC_REGRESSION_DEBUG === '1') env.HCC_DEBUG = '1';
|
|
68
|
+
env.HCC_SHUTDOWN_DIAGNOSTICS = '1';
|
|
56
69
|
delete env.TMUX;
|
|
57
70
|
delete env.TMUX_PANE;
|
|
58
71
|
|
|
@@ -61,8 +74,8 @@ function log(message) {
|
|
|
61
74
|
process.stdout.write(`${message}\n`);
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
function fail(message) {
|
|
65
|
-
const error = fixtureFailureError(message);
|
|
77
|
+
function fail(message, commandResult = null, diagnosticOperation = null) {
|
|
78
|
+
const error = fixtureFailureError(message, commandResult, diagnosticOperation);
|
|
66
79
|
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
67
80
|
const escaped = fixtureDiagnosticText({ stage: currentStage, failure: fixtureFailureDiagnostic(error) }, diagnosticSecrets)
|
|
68
81
|
.replace(/%/g, '%25')
|
|
@@ -73,25 +86,91 @@ function fail(message) {
|
|
|
73
86
|
throw error;
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
export function fixtureFailureError(message) {
|
|
89
|
+
export function fixtureFailureError(message, commandResult = null, diagnosticOperation = null) {
|
|
77
90
|
const error = new Error(String(message));
|
|
78
91
|
Object.defineProperty(error, 'fixtureStack', {
|
|
79
92
|
value: Object.freeze(new Error('fixture failure').stack.split('\n').slice(1, 7))
|
|
80
93
|
});
|
|
81
94
|
fixtureFailures.add(error);
|
|
95
|
+
const command = fixtureCommandResult(commandResult, diagnosticOperation);
|
|
96
|
+
if (command) fixtureCommandFailures.set(error, command);
|
|
82
97
|
return error;
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
export function fixtureFailureDiagnostic(error) {
|
|
86
101
|
const names = ['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AssertionError', 'AbortError'];
|
|
102
|
+
const terminalMarker = terminalMarkerFailureDiagnostic(error);
|
|
87
103
|
return {
|
|
88
104
|
name: names.includes(error?.name) ? error.name : 'Error',
|
|
89
105
|
stack: fixtureFailures.has(error)
|
|
90
106
|
? error.fixtureStack
|
|
91
|
-
: new Error('regression failure handler').stack.split('\n').slice(1, 7)
|
|
107
|
+
: new Error('regression failure handler').stack.split('\n').slice(1, 7),
|
|
108
|
+
...(fixtureCommandFailures.has(error) ? { command: fixtureCommandFailures.get(error) } : {}),
|
|
109
|
+
...(fixtureHttpFailures.has(error) ? { http: fixtureHttpFailures.get(error) } : {}),
|
|
110
|
+
...(terminalMarker ? { terminalMarker } : {})
|
|
92
111
|
};
|
|
93
112
|
}
|
|
94
113
|
|
|
114
|
+
function fixtureHttpRoute(route) {
|
|
115
|
+
if (typeof route !== 'string') return null;
|
|
116
|
+
const pathname = route.split(/[?#]/, 1)[0];
|
|
117
|
+
if (['/', '/api/projects', '/api/runtime', '/api/runtime/gc-buffers', '/api/detected',
|
|
118
|
+
'/api/sessions', '/api/sessions/attach', '/api/resumable',
|
|
119
|
+
'/assets/web/browser/core.mjs', '/assets/web/ui-agent-start.mjs'].includes(pathname)) return pathname;
|
|
120
|
+
const action = pathname.match(/^\/api\/peers\/[^/?#]+\/actions\/(status|state|inbox|heartbeat|task-next|task-takeover|lock-acquire)$/);
|
|
121
|
+
if (action) return `/api/peers/:peer/actions/${action[1]}`;
|
|
122
|
+
const session = pathname.match(/^\/api\/sessions\/[^/?#]+\/(input|stop)$/);
|
|
123
|
+
if (session) return `/api/sessions/:peer/${session[1]}`;
|
|
124
|
+
if (/^\/api\/detected\/[^/?#]+\/msg$/.test(pathname)) return '/api/detected/:peer/msg';
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function fixtureHttpCallFrames() {
|
|
129
|
+
// Capture our call site before invoking fetch. Never parse the thrown error's
|
|
130
|
+
// stack, whose text can contain URLs, request data or third-party messages.
|
|
131
|
+
const capture = {};
|
|
132
|
+
Error.captureStackTrace(capture, withFixtureHttpDiagnostic);
|
|
133
|
+
const source = pathToFileURL(path.join(repoRoot, 'scripts', 'regression.mjs')).href;
|
|
134
|
+
const location = new RegExp(`(?:^|[ (])${source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(\\d+):(\\d+)\\)?$`);
|
|
135
|
+
return Object.freeze(String(capture.stack).split('\n').flatMap((frame) => {
|
|
136
|
+
const match = frame.match(location);
|
|
137
|
+
if (!match) return [];
|
|
138
|
+
return [Object.freeze({ file: 'scripts/regression.mjs', line: Number(match[1]), column: Number(match[2]) })];
|
|
139
|
+
}).slice(0, 6));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fixtureHttpNetworkCode(error) {
|
|
143
|
+
const allowed = ['ECONNRESET', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND',
|
|
144
|
+
'EAI_AGAIN', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
|
|
145
|
+
'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET'];
|
|
146
|
+
// Reading data descriptors avoids evaluating arbitrary error/cause getters.
|
|
147
|
+
try {
|
|
148
|
+
const direct = Object.getOwnPropertyDescriptor(error, 'code')?.value;
|
|
149
|
+
const cause = Object.getOwnPropertyDescriptor(error, 'cause')?.value;
|
|
150
|
+
const nested = cause && Object.getOwnPropertyDescriptor(cause, 'code')?.value;
|
|
151
|
+
return allowed.includes(direct) ? direct : allowed.includes(nested) ? nested : null;
|
|
152
|
+
} catch { return null; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function withFixtureHttpDiagnostic(route, options, request) {
|
|
156
|
+
const method = options?.method ?? 'GET';
|
|
157
|
+
const context = {
|
|
158
|
+
method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].includes(method) ? method : null,
|
|
159
|
+
route: fixtureHttpRoute(route),
|
|
160
|
+
callFrames: fixtureHttpCallFrames()
|
|
161
|
+
};
|
|
162
|
+
const started = performance.now();
|
|
163
|
+
try {
|
|
164
|
+
return await request();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error && (typeof error === 'object' || typeof error === 'function') && !fixtureHttpFailures.has(error)) {
|
|
167
|
+
fixtureHttpFailures.set(error, Object.freeze({ ...context,
|
|
168
|
+
code: fixtureHttpNetworkCode(error), elapsedMs: Math.max(0, Math.round(performance.now() - started)) }));
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
95
174
|
export function fixtureDiagnosticText(value, secrets = []) {
|
|
96
175
|
function scrub(text) {
|
|
97
176
|
for (const secret of secrets) {
|
|
@@ -129,7 +208,9 @@ export function observeFixtureOwner(owner, { inspect = inspectProcessIdentity, p
|
|
|
129
208
|
}
|
|
130
209
|
details.birthHash = crypto.createHash('sha256').update(observed.identity.startToken).digest('hex');
|
|
131
210
|
details.commandHash = observed.identity.commandHash;
|
|
132
|
-
|
|
211
|
+
const comparison = compareProcessIdentity(owner, observed.identity);
|
|
212
|
+
if (comparison === 'dead') return { ...details, state: 'exited', reason: 'pid_reused' };
|
|
213
|
+
if (comparison !== 'live') return { ...details, state: 'unknown', reason: 'identity_incompatible' };
|
|
133
214
|
if (owner.commandHash !== observed.identity.commandHash) return { ...details, state: 'unknown', reason: 'command_changed' };
|
|
134
215
|
return { ...details, state: 'same-owner', reason: 'identity_match' };
|
|
135
216
|
}
|
|
@@ -158,62 +239,88 @@ export async function waitForFixtureOwnerExit(owner, timeoutMs, {
|
|
|
158
239
|
}
|
|
159
240
|
}
|
|
160
241
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
export function fixtureDownResult(result) {
|
|
202
|
-
// Public --json failures share stderr with Node warnings. Accept only whole
|
|
203
|
-
// independent JSON objects; ordinary output never enters the diagnostic.
|
|
204
|
-
const payload = fixtureErrorPayload(result.stdout) || fixtureErrorPayload(result.stderr);
|
|
242
|
+
function fixtureCliFailurePayload(output) {
|
|
243
|
+
if (typeof output !== 'string' || Buffer.byteLength(output) > 64 * 1024) return null;
|
|
244
|
+
const text = output.trim();
|
|
245
|
+
const parse = value => {
|
|
246
|
+
try {
|
|
247
|
+
const payload = JSON.parse(value);
|
|
248
|
+
return payload?.ok === false && !Array.isArray(payload) && payload.error && typeof payload.error === 'object' &&
|
|
249
|
+
!Array.isArray(payload.error) ? payload : null;
|
|
250
|
+
} catch { return null; }
|
|
251
|
+
};
|
|
252
|
+
const direct = parse(text);
|
|
253
|
+
if (direct) return direct;
|
|
254
|
+
// Node warnings can precede or follow the CLI's complete JSON document.
|
|
255
|
+
// Strip only bounded, recognized edge lines; never scan arbitrary logs for
|
|
256
|
+
// inner objects, which could turn a nested error into the CLI failure.
|
|
257
|
+
const lines = text.split(/\r?\n/);
|
|
258
|
+
let first = 0, last = lines.length, warningLines = 0, warningBytes = 0;
|
|
259
|
+
const warning = line =>
|
|
260
|
+
/^\(node:\d+\) (?:\[[A-Z][A-Z0-9_]*\] )?(?:[A-Za-z]+Warning|Warning):/.test(line) ||
|
|
261
|
+
/^\(Use (?:`node --trace-(?:warnings|deprecation) \.\.\.`|node --trace-(?:warnings|deprecation)(?: \.\.\.)?) to show where the warning was created\)$/.test(line);
|
|
262
|
+
const strip = line => {
|
|
263
|
+
const trimmed = line.trim();
|
|
264
|
+
if (trimmed && !warning(trimmed)) return false;
|
|
265
|
+
if (trimmed) warningLines += 1;
|
|
266
|
+
warningBytes += Buffer.byteLength(line) + 1;
|
|
267
|
+
return true;
|
|
268
|
+
};
|
|
269
|
+
while (first < last && strip(lines[first])) first += 1;
|
|
270
|
+
while (first < last && strip(lines[last - 1])) last -= 1;
|
|
271
|
+
if (warningLines > 8 || warningBytes > 4096) return null;
|
|
272
|
+
return parse(lines.slice(first, last).join('\n'));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function fixtureCliResultParts(result) {
|
|
276
|
+
// --json failures are written with console.error; stdout is retained as a
|
|
277
|
+
// compatibility fallback for fixtures. Raw output never enters diagnostics.
|
|
278
|
+
const stderrFailure = fixtureCliFailurePayload(result.stderr);
|
|
279
|
+
const stdoutFailure = fixtureCliFailurePayload(result.stdout);
|
|
280
|
+
const payload = stderrFailure || stdoutFailure;
|
|
205
281
|
const extra = payload?.error?.extra || payload?.error || {};
|
|
206
282
|
const code = payload?.error?.code || result.error?.code;
|
|
207
|
-
const
|
|
208
|
-
return {
|
|
283
|
+
const diagnostic = {
|
|
209
284
|
status: Number.isSafeInteger(result.status) ? result.status : null,
|
|
210
|
-
signal:
|
|
285
|
+
signal: typeof result.signal === 'string' && Object.hasOwn(os.constants.signals, result.signal) ? result.signal : null,
|
|
211
286
|
code: typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(code) ? code : null,
|
|
212
|
-
|
|
213
|
-
|
|
287
|
+
source: stderrFailure ? 'stderr' : stdoutFailure ? 'stdout' : result.error?.code ? 'spawn' : null,
|
|
288
|
+
stdoutBytes: typeof result.stdout === 'string' ? Buffer.byteLength(result.stdout) : 0,
|
|
289
|
+
stderrBytes: typeof result.stderr === 'string' ? Buffer.byteLength(result.stderr) : 0
|
|
290
|
+
};
|
|
291
|
+
return { diagnostic, extra };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function fixtureDownResult(result) {
|
|
295
|
+
const { diagnostic, extra } = fixtureCliResultParts(result);
|
|
296
|
+
const numeric = ['pid', 'elapsedMs', 'timeoutMs', 'stopElapsedMs', 'confirmationMs'];
|
|
297
|
+
return {
|
|
298
|
+
...diagnostic,
|
|
299
|
+
extra: {
|
|
300
|
+
...Object.fromEntries(numeric.filter(key => typeof extra[key] === 'number' &&
|
|
301
|
+
Number.isFinite(extra[key]) && extra[key] >= 0).map(key => [key, extra[key]])),
|
|
302
|
+
...(['live', 'dead', 'unknown'].includes(extra.state) ? { state: extra.state } : {}),
|
|
303
|
+
...(['evidence_unavailable', 'exit_unconfirmed', 'pointer_cleanup_failed', 'pointer_changed'].includes(extra.stopPhase)
|
|
304
|
+
? { stopPhase: extra.stopPhase } : {})
|
|
305
|
+
}
|
|
214
306
|
};
|
|
215
307
|
}
|
|
216
308
|
|
|
309
|
+
function fixtureCommandResult(result, operation) {
|
|
310
|
+
// Only this explicit fixture operation opts in. Never copy command arguments,
|
|
311
|
+
// error messages, output, URLs or peer identifiers into public annotations.
|
|
312
|
+
if (operation !== 'broadcast' || !result || typeof result !== 'object') return null;
|
|
313
|
+
const { diagnostic, extra } = fixtureCliResultParts(result);
|
|
314
|
+
const context = Object.fromEntries(['elapsedMs', 'timeoutMs'].filter(key =>
|
|
315
|
+
Number.isSafeInteger(extra[key]) && extra[key] >= 0).map(key => [key, extra[key]]));
|
|
316
|
+
if (Number.isSafeInteger(extra.status) && extra.status >= 100 && extra.status <= 599) context.status = extra.status;
|
|
317
|
+
if (extra.method === 'GET' || extra.method === 'POST') context.method = extra.method;
|
|
318
|
+
const route = typeof extra.path === 'string' ? extra.path.split(/[?#]/, 1)[0] : '';
|
|
319
|
+
if (route === '/api/sessions') context.path = route;
|
|
320
|
+
else if (/^\/api\/sessions\/[^/?#]+\/input$/.test(route)) context.path = '/api/sessions/:peer/input';
|
|
321
|
+
return Object.freeze({ operation, ...diagnostic, extra: Object.freeze(context) });
|
|
322
|
+
}
|
|
323
|
+
|
|
217
324
|
export function fixtureStopEnvironment(base) {
|
|
218
325
|
const local = { ...base, HCC_RUNTIME_LOCAL_ONLY: '1' };
|
|
219
326
|
delete local.HCC_RUNTIME_URL;
|
|
@@ -239,6 +346,13 @@ function rememberRuntimePid(pid) {
|
|
|
239
346
|
function collectRuntimeDiagnostic(extra = {}) {
|
|
240
347
|
const diagnostic = { stage: currentStage, pid: runtimePid,
|
|
241
348
|
owner: observeFixtureOwner(runtimeOwner), pointerExists: fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), ...extra };
|
|
349
|
+
let logFd;
|
|
350
|
+
try {
|
|
351
|
+
logFd = fs.openSync(path.join(root, '.hello-cc', 'web.log'), 'r');
|
|
352
|
+
const size = fs.fstatSync(logFd).size, bytes = Buffer.alloc(Math.min(size, 65536));
|
|
353
|
+
fs.readSync(logFd, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
|
|
354
|
+
diagnostic.shutdownPhases = parseShutdownDiagnostics(bytes.toString('utf8'), runtimePid);
|
|
355
|
+
} catch {} finally { if (logFd !== undefined) fs.closeSync(logFd); }
|
|
242
356
|
try {
|
|
243
357
|
const db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { readOnly: true, timeout: 200 });
|
|
244
358
|
try { diagnostic.events = db.prepare('SELECT id, type FROM events ORDER BY id DESC LIMIT 12').all(); }
|
|
@@ -260,6 +374,10 @@ function canonicalPath(value) {
|
|
|
260
374
|
catch { return path.resolve(value); }
|
|
261
375
|
}
|
|
262
376
|
|
|
377
|
+
export function isRegressionEntry(entry = process.argv[1], filename = import.meta.filename) {
|
|
378
|
+
return Boolean(entry) && canonicalPath(entry) === canonicalPath(filename);
|
|
379
|
+
}
|
|
380
|
+
|
|
263
381
|
function samePath(a, b) {
|
|
264
382
|
return canonicalPath(a) === canonicalPath(b);
|
|
265
383
|
}
|
|
@@ -296,7 +414,7 @@ function run(command, args, options = {}) {
|
|
|
296
414
|
});
|
|
297
415
|
if (result.status !== 0) {
|
|
298
416
|
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
299
|
-
fail(`${commandText(command, args)} failed${output ? `\n${output}` : ''}
|
|
417
|
+
fail(`${commandText(command, args)} failed${output ? `\n${output}` : ''}`, result, options.diagnosticOperation);
|
|
300
418
|
}
|
|
301
419
|
return result.stdout || '';
|
|
302
420
|
}
|
|
@@ -1601,11 +1719,13 @@ function currentRuntimeUrl(route, params = {}) {
|
|
|
1601
1719
|
return runtimeUrl(currentRuntime(), route, params);
|
|
1602
1720
|
}
|
|
1603
1721
|
|
|
1604
|
-
function runtimeFetch(route, options = {}, params = {}) {
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1722
|
+
export function runtimeFetch(route, options = {}, params = {}) {
|
|
1723
|
+
return withFixtureHttpDiagnostic(route, options, () => {
|
|
1724
|
+
const runtime = currentRuntime();
|
|
1725
|
+
const headers = { ...(options.headers || {}), 'X-HCC-API-Version': '2' };
|
|
1726
|
+
if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
|
|
1727
|
+
return fetch(runtimeUrl(runtime, route, params), { ...options, headers });
|
|
1728
|
+
});
|
|
1609
1729
|
}
|
|
1610
1730
|
|
|
1611
1731
|
function directTlsRequest(runtime, route, options = {}) {
|
|
@@ -1613,6 +1733,7 @@ function directTlsRequest(runtime, route, options = {}) {
|
|
|
1613
1733
|
const body = options.body === undefined || options.body === null ? null : String(options.body);
|
|
1614
1734
|
return new Promise((resolve, reject) => {
|
|
1615
1735
|
const req = https.request({
|
|
1736
|
+
agent: false,
|
|
1616
1737
|
method: options.method || 'GET',
|
|
1617
1738
|
hostname: url.hostname,
|
|
1618
1739
|
port: url.port || 443,
|
|
@@ -1700,17 +1821,37 @@ async function waitForFile(file, expected, label = file) {
|
|
|
1700
1821
|
ensureFile(file, expected);
|
|
1701
1822
|
}
|
|
1702
1823
|
|
|
1703
|
-
async function waitForFileContent(file, expected, label = file) {
|
|
1704
|
-
const
|
|
1824
|
+
async function waitForFileContent(file, expected, label = file, { diagnostics = null, ownedReentryOutput = false } = {}) {
|
|
1825
|
+
const startedAt = Date.now();
|
|
1826
|
+
const deadline = startedAt + 10000;
|
|
1705
1827
|
let actual = '(missing)';
|
|
1828
|
+
let lastReadAt = null;
|
|
1706
1829
|
while (Date.now() < deadline) {
|
|
1707
1830
|
if (fs.existsSync(file)) {
|
|
1831
|
+
lastReadAt = Date.now();
|
|
1708
1832
|
actual = fs.readFileSync(file, 'utf8').trim();
|
|
1709
1833
|
if (actual === expected) break;
|
|
1710
1834
|
}
|
|
1711
1835
|
await sleep(100);
|
|
1712
1836
|
}
|
|
1837
|
+
let finalObservation = null;
|
|
1838
|
+
if (actual !== expected && ownedReentryOutput) {
|
|
1839
|
+
finalObservation = readOwnedReentryOutputAtDeadline(file, expected, deadline, Date.now());
|
|
1840
|
+
if (finalObservation.matched) actual = expected;
|
|
1841
|
+
}
|
|
1713
1842
|
if (actual !== expected) {
|
|
1843
|
+
if (diagnostics) {
|
|
1844
|
+
const detail = JSON.stringify({ ...diagnostics(), wait: {
|
|
1845
|
+
started_at_ms: startedAt, deadline_ms: deadline, last_read_at_ms: lastReadAt,
|
|
1846
|
+
final_observation: finalObservation
|
|
1847
|
+
} });
|
|
1848
|
+
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
1849
|
+
const escaped = detail.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
1850
|
+
process.stderr.write(`::error title=Shim regression diagnostic::${escaped}\n`);
|
|
1851
|
+
} else {
|
|
1852
|
+
process.stderr.write(`Shim regression diagnostic: ${detail}\n`);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1714
1855
|
fail(`timed out waiting for ${label}\nexpected: ${expected}\nactual: ${actual}`);
|
|
1715
1856
|
}
|
|
1716
1857
|
ensureFile(file, expected);
|
|
@@ -1751,9 +1892,9 @@ function assertHtmlCsp(response, html, label) {
|
|
|
1751
1892
|
? "default-src 'self'; " +
|
|
1752
1893
|
`script-src 'self' 'nonce-${nonce}'; ` +
|
|
1753
1894
|
"style-src 'self' 'unsafe-inline'; " +
|
|
1754
|
-
"img-src 'self' data:; " +
|
|
1895
|
+
"img-src 'self' data: blob:; " +
|
|
1755
1896
|
"connect-src 'self' ws: wss:; " +
|
|
1756
|
-
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
|
1897
|
+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'self' blob:"
|
|
1757
1898
|
: '';
|
|
1758
1899
|
if (!nonce || policy !== expected) {
|
|
1759
1900
|
fail(`${label} missing complete nonce CSP: ${policy || '(missing)'}`);
|
|
@@ -1775,34 +1916,10 @@ async function waitForProcessExit(pid, label, timeoutMs = 5000) {
|
|
|
1775
1916
|
}
|
|
1776
1917
|
|
|
1777
1918
|
async function expectWebSocketMarker(peer, marker) {
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
const timer = setTimeout(() => {
|
|
1783
|
-
try { ws.terminate(); } catch {}
|
|
1784
|
-
reject(new Error(`${peer} websocket timeout`));
|
|
1785
|
-
}, TERMINAL_WEBSOCKET_TIMEOUT_MS);
|
|
1786
|
-
ws.on('message', (raw) => {
|
|
1787
|
-
const msg = JSON.parse(String(raw));
|
|
1788
|
-
if (msg.type === 'snapshot') {
|
|
1789
|
-
sawSnapshot = true;
|
|
1790
|
-
claimTerminalControl(ws).then(() => ws.send(JSON.stringify({
|
|
1791
|
-
type: 'input', data: `echo ${marker}\r`, action_token: ws.hccActionToken,
|
|
1792
|
-
epoch: ws.hccControl.epoch
|
|
1793
|
-
}))).catch(reject);
|
|
1794
|
-
}
|
|
1795
|
-
if (['snapshot', 'data', 'replace'].includes(msg.type) && String(msg.data || '').includes(marker)) {
|
|
1796
|
-
sawMarker = true;
|
|
1797
|
-
}
|
|
1798
|
-
if (sawSnapshot && sawMarker) {
|
|
1799
|
-
clearTimeout(timer);
|
|
1800
|
-
releaseTerminalControl(ws);
|
|
1801
|
-
ws.close();
|
|
1802
|
-
resolve();
|
|
1803
|
-
}
|
|
1804
|
-
});
|
|
1805
|
-
ws.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
1919
|
+
const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
|
|
1920
|
+
await waitForTerminalMarker(ws, marker, {
|
|
1921
|
+
claim: signal => claimTerminalControl(ws, { signal }),
|
|
1922
|
+
release: () => releaseTerminalControl(ws), timeoutMs: TERMINAL_WEBSOCKET_TIMEOUT_MS
|
|
1806
1923
|
});
|
|
1807
1924
|
}
|
|
1808
1925
|
|
|
@@ -1812,31 +1929,44 @@ function trackTerminalControl(ws) {
|
|
|
1812
1929
|
ws.on('message', (raw) => {
|
|
1813
1930
|
let message;
|
|
1814
1931
|
try { message = JSON.parse(String(raw)); } catch { return; }
|
|
1932
|
+
if (!message || typeof message !== 'object') return;
|
|
1815
1933
|
if (message.action_token) ws.hccActionToken = message.action_token;
|
|
1816
1934
|
if (message.control) ws.hccControl = message.control;
|
|
1817
1935
|
});
|
|
1818
1936
|
return ws;
|
|
1819
1937
|
}
|
|
1820
1938
|
|
|
1821
|
-
async function claimTerminalControl(ws) {
|
|
1939
|
+
export async function claimTerminalControl(ws, { signal } = {}) {
|
|
1940
|
+
const aborted = () => Object.assign(new Error('terminal control claim aborted'), { code: 'TERMINAL_CONTROL_ABORTED' });
|
|
1941
|
+
if (signal?.aborted) throw aborted();
|
|
1822
1942
|
if (ws.hccControl?.can_control) return ws.hccControl;
|
|
1823
1943
|
return new Promise((resolve, reject) => {
|
|
1824
1944
|
const timer = setTimeout(() => finish(new Error('terminal control claim timeout')), 5000);
|
|
1945
|
+
let settled = false;
|
|
1825
1946
|
const finish = (error = null) => {
|
|
1947
|
+
if (settled) return;
|
|
1948
|
+
settled = true;
|
|
1826
1949
|
clearTimeout(timer);
|
|
1827
1950
|
ws.off('message', onMessage);
|
|
1951
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1828
1952
|
if (error) reject(error);
|
|
1829
1953
|
else resolve(ws.hccControl);
|
|
1830
1954
|
};
|
|
1831
1955
|
const onMessage = (raw) => {
|
|
1956
|
+
if (settled) return;
|
|
1832
1957
|
let message;
|
|
1833
1958
|
try { message = JSON.parse(String(raw)); } catch { return; }
|
|
1959
|
+
if (!message || typeof message !== 'object') return;
|
|
1834
1960
|
if (message.type === 'error') finish(new Error(`terminal control claim failed: ${JSON.stringify(message.error)}`));
|
|
1835
1961
|
else if (message.type === 'control' && message.control?.can_control) finish();
|
|
1836
1962
|
};
|
|
1963
|
+
const onAbort = () => finish(aborted());
|
|
1837
1964
|
ws.on('message', onMessage);
|
|
1838
|
-
|
|
1839
|
-
|
|
1965
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1966
|
+
try {
|
|
1967
|
+
ws.send(JSON.stringify({ type: 'control', action: 'claim', action_token: ws.hccActionToken,
|
|
1968
|
+
epoch: ws.hccControl?.epoch, force: true }));
|
|
1969
|
+
} catch (error) { finish(error); }
|
|
1840
1970
|
});
|
|
1841
1971
|
}
|
|
1842
1972
|
|
|
@@ -1949,24 +2079,60 @@ async function fetchTerminalSnapshot(peer, params = {}) {
|
|
|
1949
2079
|
});
|
|
1950
2080
|
}
|
|
1951
2081
|
|
|
1952
|
-
|
|
2082
|
+
function browserSessionCookie(setCookie, endpoint, { expired = false } = {}) {
|
|
2083
|
+
const url = new URL(endpoint);
|
|
2084
|
+
const protocol = url.protocol.slice(0, -1);
|
|
2085
|
+
const effectivePort = url.port || (protocol === 'https' ? '443' : '80');
|
|
2086
|
+
const expectedName = `hcc_sid_v2_${protocol}_${effectivePort}`;
|
|
2087
|
+
const match = String(setCookie).match(/^([^=;\s]+)=([^;]*)/);
|
|
2088
|
+
if (!match || match[1] !== expectedName || (expired ? match[2] !== '' : !match[2])) {
|
|
2089
|
+
fail(`browser session cookie did not match endpoint scope ${expectedName} (expired=${expired})`);
|
|
2090
|
+
}
|
|
2091
|
+
return { name: match[1], sid: match[2], cookie: `${match[1]}=${match[2]}` };
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
async function selectCookieProject(baseUrl, cookie, projectRoot = root, {
|
|
2095
|
+
signal, origin = new URL(baseUrl).origin, headers = {}
|
|
2096
|
+
} = {}) {
|
|
2097
|
+
const url = new URL('/api/projects/select', baseUrl);
|
|
2098
|
+
url.searchParams.set('root', projectRoot);
|
|
2099
|
+
const response = await fetch(url, {
|
|
2100
|
+
method: 'POST',
|
|
2101
|
+
headers: {
|
|
2102
|
+
...headers,
|
|
2103
|
+
Cookie: cookie,
|
|
2104
|
+
Origin: origin,
|
|
2105
|
+
'X-HCC-API-Version': '2'
|
|
2106
|
+
},
|
|
2107
|
+
signal
|
|
2108
|
+
});
|
|
2109
|
+
const selected = await response.json();
|
|
2110
|
+
if (!response.ok || !selected.project_identity) {
|
|
2111
|
+
fail(`cookie project selection failed: status=${response.status} body=${JSON.stringify(selected)}`);
|
|
2112
|
+
}
|
|
2113
|
+
return selected.project_identity;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
async function issueBrowserSessionCookie({ signal, selectProject = true } = {}) {
|
|
1953
2117
|
const runtime = currentRuntime();
|
|
1954
2118
|
const baseUrl = runtime.base_url || `http://127.0.0.1:${port}`;
|
|
1955
2119
|
const response = await fetch(new URL('/login', baseUrl), {
|
|
1956
2120
|
method: 'POST',
|
|
1957
2121
|
headers: { 'Content-Type': 'application/json' },
|
|
1958
2122
|
body: JSON.stringify({ token: runtime.token || '' }),
|
|
2123
|
+
signal,
|
|
1959
2124
|
redirect: 'manual'
|
|
1960
2125
|
});
|
|
1961
2126
|
const setCookie = response.headers.get('set-cookie') || '';
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
fail(`browser session login failed: status=${response.status} cookie=${setCookie}`);
|
|
2127
|
+
if (response.status !== 302) {
|
|
2128
|
+
fail(`browser session login failed: status=${response.status}`);
|
|
1965
2129
|
}
|
|
1966
|
-
|
|
2130
|
+
const auth = browserSessionCookie(setCookie, baseUrl);
|
|
2131
|
+
const projectIdentity = selectProject ? await selectCookieProject(baseUrl, auth.cookie, root, { signal }) : null;
|
|
2132
|
+
return { baseUrl, origin: new URL(baseUrl).origin, ...auth, projectIdentity };
|
|
1967
2133
|
}
|
|
1968
2134
|
|
|
1969
|
-
async function cookieRuntimeFetch(route, auth, options = {}, params = {}) {
|
|
2135
|
+
export async function cookieRuntimeFetch(route, auth, options = {}, params = {}) {
|
|
1970
2136
|
const url = new URL(route, auth.baseUrl);
|
|
1971
2137
|
for (const [key, value] of Object.entries(params)) {
|
|
1972
2138
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
@@ -1974,9 +2140,10 @@ async function cookieRuntimeFetch(route, auth, options = {}, params = {}) {
|
|
|
1974
2140
|
return fetch(url, {
|
|
1975
2141
|
...options,
|
|
1976
2142
|
headers: {
|
|
1977
|
-
Cookie:
|
|
2143
|
+
Cookie: auth.cookie,
|
|
1978
2144
|
Origin: auth.origin,
|
|
1979
2145
|
'X-HCC-API-Version': '2',
|
|
2146
|
+
...(auth.projectIdentity ? { 'X-HCC-Root-Identity': auth.projectIdentity } : {}),
|
|
1980
2147
|
...(options.headers || {})
|
|
1981
2148
|
}
|
|
1982
2149
|
});
|
|
@@ -1990,8 +2157,9 @@ async function cookieRuntimeFetchWithoutOrigin(route, auth, options = {}, params
|
|
|
1990
2157
|
return fetch(url, {
|
|
1991
2158
|
...options,
|
|
1992
2159
|
headers: {
|
|
1993
|
-
Cookie:
|
|
2160
|
+
Cookie: auth.cookie,
|
|
1994
2161
|
'X-HCC-API-Version': '2',
|
|
2162
|
+
...(auth.projectIdentity ? { 'X-HCC-Root-Identity': auth.projectIdentity } : {}),
|
|
1995
2163
|
...(options.headers || {})
|
|
1996
2164
|
}
|
|
1997
2165
|
});
|
|
@@ -2017,9 +2185,10 @@ async function expectSocketMarkerAfter(ws, marker, action) {
|
|
|
2017
2185
|
});
|
|
2018
2186
|
}
|
|
2019
2187
|
|
|
2020
|
-
async function openCookieTerminalWebSocket(peer,
|
|
2188
|
+
async function openCookieTerminalWebSocket(peer, cookie, params = {}, selectedProjectIdentity = null) {
|
|
2021
2189
|
const runtime = currentRuntime();
|
|
2022
2190
|
const baseUrl = runtime.base_url || `http://127.0.0.1:${port}`;
|
|
2191
|
+
const projectIdentity = selectedProjectIdentity || await selectCookieProject(baseUrl, cookie, params.root || root);
|
|
2023
2192
|
const url = new URL(`/ws/terminal/${encodeURIComponent(peer)}`, baseUrl);
|
|
2024
2193
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
2025
2194
|
url.searchParams.set('api_version', '2');
|
|
@@ -2030,8 +2199,9 @@ async function openCookieTerminalWebSocket(peer, sid, params = {}) {
|
|
|
2030
2199
|
let settled = false;
|
|
2031
2200
|
const ws = trackTerminalControl(new WebSocket(url, {
|
|
2032
2201
|
headers: {
|
|
2033
|
-
Cookie:
|
|
2034
|
-
Origin: new URL(baseUrl).origin
|
|
2202
|
+
Cookie: cookie,
|
|
2203
|
+
Origin: new URL(baseUrl).origin,
|
|
2204
|
+
'X-HCC-Root-Identity': projectIdentity
|
|
2035
2205
|
}
|
|
2036
2206
|
}));
|
|
2037
2207
|
const timer = setTimeout(() => {
|
|
@@ -2039,7 +2209,7 @@ async function openCookieTerminalWebSocket(peer, sid, params = {}) {
|
|
|
2039
2209
|
settled = true;
|
|
2040
2210
|
try { ws.terminate(); } catch {}
|
|
2041
2211
|
reject(new Error(`${peer} cookie websocket snapshot timeout`));
|
|
2042
|
-
},
|
|
2212
|
+
}, COOKIE_WEBSOCKET_SNAPSHOT_TIMEOUT_MS);
|
|
2043
2213
|
const rejectBeforeSnapshot = (err) => {
|
|
2044
2214
|
if (settled) return;
|
|
2045
2215
|
settled = true;
|
|
@@ -2071,12 +2241,12 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
|
|
|
2071
2241
|
redirect: 'manual'
|
|
2072
2242
|
});
|
|
2073
2243
|
const setCookie = login.headers.get('set-cookie') || '';
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
fail(`cookie websocket login failed: status=${login.status} cookie=${setCookie}`);
|
|
2244
|
+
if (login.status !== 302) {
|
|
2245
|
+
fail(`cookie websocket login failed: status=${login.status}`);
|
|
2077
2246
|
}
|
|
2247
|
+
const auth = browserSessionCookie(setCookie, baseUrl);
|
|
2078
2248
|
|
|
2079
|
-
const ws = await openCookieTerminalWebSocket(peer,
|
|
2249
|
+
const ws = await openCookieTerminalWebSocket(peer, auth.cookie, params);
|
|
2080
2250
|
try {
|
|
2081
2251
|
const closed = new Promise((resolve, reject) => {
|
|
2082
2252
|
const timer = setTimeout(() => reject(new Error(`${peer} cookie websocket logout close timeout`)), 5000);
|
|
@@ -2093,7 +2263,7 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
|
|
|
2093
2263
|
fetch(new URL('/logout', baseUrl), {
|
|
2094
2264
|
method: 'POST',
|
|
2095
2265
|
headers: {
|
|
2096
|
-
Cookie:
|
|
2266
|
+
Cookie: auth.cookie,
|
|
2097
2267
|
Origin: origin
|
|
2098
2268
|
}
|
|
2099
2269
|
}),
|
|
@@ -2102,11 +2272,12 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
|
|
|
2102
2272
|
if (logout.status !== 204 || !(logout.headers.get('set-cookie') || '').includes('Max-Age=0')) {
|
|
2103
2273
|
fail(`cookie websocket logout did not expire its session: status=${logout.status}`);
|
|
2104
2274
|
}
|
|
2275
|
+
browserSessionCookie(logout.headers.get('set-cookie') || '', baseUrl, { expired: true });
|
|
2105
2276
|
if (closeResult.code !== 4001 || ws.readyState !== WebSocket.CLOSED) {
|
|
2106
2277
|
fail(`cookie websocket logout did not close the established socket with 4001:\n${JSON.stringify({ closeResult, readyState: ws.readyState }, null, 2)}`);
|
|
2107
2278
|
}
|
|
2108
2279
|
const revoked = await fetch(new URL('/api/runtime', baseUrl), {
|
|
2109
|
-
headers: { Cookie:
|
|
2280
|
+
headers: { Cookie: auth.cookie, 'X-HCC-API-Version': '2' }
|
|
2110
2281
|
});
|
|
2111
2282
|
if (revoked.status !== 401) {
|
|
2112
2283
|
fail(`cookie websocket logout left the old cookie authorized: ${revoked.status}`);
|
|
@@ -2120,7 +2291,7 @@ async function assertLogoutClosesCookieWebSocket(peer, params = {}) {
|
|
|
2120
2291
|
|
|
2121
2292
|
async function assertEvictionClosesCookieWebSocket(peer, params = {}) {
|
|
2122
2293
|
const auth = await issueBrowserSessionCookie();
|
|
2123
|
-
const ws = await openCookieTerminalWebSocket(peer, auth.
|
|
2294
|
+
const ws = await openCookieTerminalWebSocket(peer, auth.cookie, params, auth.projectIdentity);
|
|
2124
2295
|
try {
|
|
2125
2296
|
const closed = new Promise((resolve, reject) => {
|
|
2126
2297
|
const timer = setTimeout(() => reject(new Error(`${peer} cookie websocket eviction close timeout`)), 10000);
|
|
@@ -2134,14 +2305,14 @@ async function assertEvictionClosesCookieWebSocket(peer, params = {}) {
|
|
|
2134
2305
|
});
|
|
2135
2306
|
});
|
|
2136
2307
|
for (let offset = 0; offset < 256; offset += 32) {
|
|
2137
|
-
await Promise.all(Array.from({ length: 32 }, () => issueBrowserSessionCookie()));
|
|
2308
|
+
await Promise.all(Array.from({ length: 32 }, () => issueBrowserSessionCookie({ selectProject: false })));
|
|
2138
2309
|
}
|
|
2139
2310
|
const closeResult = await closed;
|
|
2140
2311
|
if (closeResult.code !== 4001 || !closeResult.reason.includes('session limit reached')) {
|
|
2141
2312
|
fail(`cookie websocket eviction did not close with 4001:\n${JSON.stringify(closeResult, null, 2)}`);
|
|
2142
2313
|
}
|
|
2143
2314
|
const revoked = await fetch(new URL('/api/runtime', auth.baseUrl), {
|
|
2144
|
-
headers: { Cookie:
|
|
2315
|
+
headers: { Cookie: auth.cookie, 'X-HCC-API-Version': '2' }
|
|
2145
2316
|
});
|
|
2146
2317
|
if (revoked.status !== 401) fail(`evicted cookie remained authorized: ${revoked.status}`);
|
|
2147
2318
|
} finally {
|
|
@@ -2621,7 +2792,7 @@ async function assertShimIgnoresGlobalRuntime(generateShim) {
|
|
|
2621
2792
|
started_at: Math.floor(Date.now() / 1000),
|
|
2622
2793
|
global_runtime: true
|
|
2623
2794
|
};
|
|
2624
|
-
fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2));
|
|
2795
|
+
fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2), { mode: 0o600 });
|
|
2625
2796
|
|
|
2626
2797
|
const hccWrapper = path.join(testDir, 'hcc-wrapper');
|
|
2627
2798
|
fs.writeFileSync(hccWrapper, `#!/usr/bin/env bash\nexec ${sh(process.execPath)} ${sh(hccBin)} "$@"\n`, { mode: 0o755 });
|
|
@@ -2731,7 +2902,7 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2731
2902
|
env: {
|
|
2732
2903
|
...env,
|
|
2733
2904
|
HCC_REGRESSION_TEST: '1',
|
|
2734
|
-
HCC_REGRESSION_WEB_SESSION_TTL_SEC:
|
|
2905
|
+
HCC_REGRESSION_WEB_SESSION_TTL_SEC: String(COOKIE_EXPIRY_FIXTURE_TTL_SEC)
|
|
2735
2906
|
}
|
|
2736
2907
|
});
|
|
2737
2908
|
try {
|
|
@@ -2757,40 +2928,56 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2757
2928
|
}
|
|
2758
2929
|
|
|
2759
2930
|
const auth = await issueBrowserSessionCookie();
|
|
2760
|
-
ws = await openCookieTerminalWebSocket(expirySessionId, auth.
|
|
2931
|
+
ws = await openCookieTerminalWebSocket(expirySessionId, auth.cookie, { root }, auth.projectIdentity);
|
|
2761
2932
|
if (!ws.hccActionToken) fail('short-TTL cookie terminal snapshot omitted its action token');
|
|
2762
|
-
const
|
|
2763
|
-
|
|
2933
|
+
const expiryWindow = createCookieExpiryWindow(COOKIE_EXPIRY_FIXTURE_TTL_SEC * 1000);
|
|
2934
|
+
// Listen before waiting so spontaneous expiry is retained. Start the close
|
|
2935
|
+
// deadline only after expiry, and defer rejection to avoid an unhandled
|
|
2936
|
+
// error while this fixture is still waiting for the cookie to expire.
|
|
2937
|
+
const closed = new Promise(resolve => {
|
|
2764
2938
|
ws.once('close', (code, reason) => {
|
|
2765
|
-
clearTimeout(timer);
|
|
2766
2939
|
resolve({ code, reason: String(reason || '') });
|
|
2767
2940
|
});
|
|
2768
|
-
ws.once('error',
|
|
2769
|
-
clearTimeout(timer);
|
|
2770
|
-
reject(err);
|
|
2771
|
-
});
|
|
2941
|
+
ws.once('error', error => resolve({ error }));
|
|
2772
2942
|
});
|
|
2773
2943
|
|
|
2774
|
-
|
|
2944
|
+
// A probe-only cookie is issued after the target cookie. Observing its
|
|
2945
|
+
// expiry must not revoke the target socket before its input guard runs.
|
|
2946
|
+
const probeAuth = await expiryWindow.prepare(signal => issueBrowserSessionCookie({ signal }));
|
|
2947
|
+
// Cookie expiry uses the server's wall clock, which can advance more slowly
|
|
2948
|
+
// than this fixture's timer. Observe real expiry within the original total
|
|
2949
|
+
// TTL + close budget before sending input on this same socket.
|
|
2950
|
+
await expiryWindow.waitForHttpExpiry(signal =>
|
|
2951
|
+
cookieRuntimeFetch('/api/runtime', probeAuth, { signal }));
|
|
2952
|
+
if (ws.readyState !== WebSocket.OPEN) fail('target cookie websocket closed before the expired-input check');
|
|
2775
2953
|
ws.send(JSON.stringify({
|
|
2776
2954
|
type: 'input',
|
|
2777
2955
|
data: `echo ${marker}\r`,
|
|
2778
2956
|
action_token: ws.hccActionToken,
|
|
2779
2957
|
epoch: ws.hccControl.epoch
|
|
2780
2958
|
}));
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2959
|
+
let closeError = null;
|
|
2960
|
+
const closeResult = await expiryWindow.waitForClose(closed).catch(error => {
|
|
2961
|
+
closeError = error; return null;
|
|
2962
|
+
});
|
|
2963
|
+
// Inspect the HTTP and input boundaries even if the close observation
|
|
2964
|
+
// times out; a missing close event must not hide accepted terminal input.
|
|
2786
2965
|
const expiredHttp = await cookieRuntimeFetch('/api/runtime', auth);
|
|
2966
|
+
const snapshot = await fetchTerminalSnapshot(expirySessionId, { root });
|
|
2967
|
+
const inputRejected = !snapshot.includes(marker);
|
|
2787
2968
|
if (expiredHttp.status !== 401) {
|
|
2788
2969
|
fail(`expired browser cookie remained authorized over HTTP: ${expiredHttp.status}`);
|
|
2789
2970
|
}
|
|
2790
|
-
|
|
2791
|
-
if (snapshot.includes(marker)) {
|
|
2971
|
+
if (!inputRejected) {
|
|
2792
2972
|
fail(`expired browser cookie executed terminal input after expiry:\n${snapshot}`);
|
|
2793
2973
|
}
|
|
2974
|
+
if (closeError) {
|
|
2975
|
+
fail(`expired cookie websocket close failed: code=${closeError.code || 'CLOSE_FAILED'} httpStatus=${expiredHttp.status} inputRejected=${inputRejected}`);
|
|
2976
|
+
}
|
|
2977
|
+
if (closeResult.error) throw closeResult.error;
|
|
2978
|
+
if (closeResult.code !== 4001 || !closeResult.reason.includes('session expired')) {
|
|
2979
|
+
fail(`expired cookie websocket did not close with 4001/session expired:\n${JSON.stringify(closeResult, null, 2)}`);
|
|
2980
|
+
}
|
|
2794
2981
|
} finally {
|
|
2795
2982
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
2796
2983
|
try { ws.terminate(); } catch {}
|
|
@@ -2829,8 +3016,8 @@ async function webSecretRedactionWorkflow() {
|
|
|
2829
3016
|
body: JSON.stringify({ token: secret }),
|
|
2830
3017
|
redirect: 'manual'
|
|
2831
3018
|
});
|
|
2832
|
-
|
|
2833
|
-
|
|
3019
|
+
if (login.status !== 302) fail(`redaction login failed with status ${login.status}`);
|
|
3020
|
+
const auth = browserSessionCookie(login.headers.get('set-cookie') || '', runtime.base_url);
|
|
2834
3021
|
|
|
2835
3022
|
const create = await runtimeFetch('/api/sessions', {
|
|
2836
3023
|
method: 'POST',
|
|
@@ -2844,7 +3031,7 @@ async function webSecretRedactionWorkflow() {
|
|
|
2844
3031
|
})
|
|
2845
3032
|
}, { root });
|
|
2846
3033
|
if (!create.ok) fail(`redaction PTY create failed with status ${create.status}`);
|
|
2847
|
-
terminalWs = await openCookieTerminalWebSocket(sessionId,
|
|
3034
|
+
terminalWs = await openCookieTerminalWebSocket(sessionId, auth.cookie, { root });
|
|
2848
3035
|
terminalWs.send(JSON.stringify({ type: 'resize', cols: 90, rows: 28,
|
|
2849
3036
|
action_token: terminalWs.hccActionToken, epoch: terminalWs.hccControl.epoch }));
|
|
2850
3037
|
|
|
@@ -2861,7 +3048,7 @@ async function webSecretRedactionWorkflow() {
|
|
|
2861
3048
|
|
|
2862
3049
|
const invalidCookie = await fetch(new URL('/api/runtime', runtime.base_url), {
|
|
2863
3050
|
headers: {
|
|
2864
|
-
Cookie:
|
|
3051
|
+
Cookie: `${auth.name}=${secret}`,
|
|
2865
3052
|
'X-HCC-API-Version': '2'
|
|
2866
3053
|
}
|
|
2867
3054
|
});
|
|
@@ -2940,12 +3127,15 @@ async function assertWebWrapperParentSurvives() {
|
|
|
2940
3127
|
}
|
|
2941
3128
|
}
|
|
2942
3129
|
|
|
2943
|
-
async function stopRuntime() {
|
|
3130
|
+
async function stopRuntime({ peerStops = [] } = {}) {
|
|
2944
3131
|
if (!runtimePid) return;
|
|
2945
3132
|
const owner = runtimeOwner;
|
|
2946
3133
|
const before = observeFixtureOwner(owner);
|
|
3134
|
+
const downStarted = performance.now();
|
|
2947
3135
|
const down = fixtureDownResult(stopFixtureCli());
|
|
2948
|
-
|
|
3136
|
+
const downDurationMs = Math.round(performance.now() - downStarted);
|
|
3137
|
+
const afterDown = observeFixtureOwner(owner);
|
|
3138
|
+
lastStopDiagnostic = { before, down, downDurationMs, afterDown, peerStops, stack: new Error('stopRuntime').stack.split('\n').slice(1, 7), signals: [] };
|
|
2949
3139
|
try {
|
|
2950
3140
|
await waitForFixtureOwnerExit(owner, 5000);
|
|
2951
3141
|
} catch (err) {
|
|
@@ -3174,14 +3364,14 @@ async function setupRegression() {
|
|
|
3174
3364
|
{ headers: { Accept: 'text/html' } }
|
|
3175
3365
|
);
|
|
3176
3366
|
const directTlsSetCookie = String(directTlsExchange.headers['set-cookie']?.[0] || '');
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
fail(`direct TLS login did not issue a Secure cookie: status=${directTlsExchange.status} cookie=${directTlsSetCookie}`);
|
|
3367
|
+
if (directTlsExchange.status !== 302 || !directTlsSetCookie.includes('Secure')) {
|
|
3368
|
+
fail(`direct TLS login did not issue a Secure cookie: status=${directTlsExchange.status}`);
|
|
3180
3369
|
}
|
|
3370
|
+
const directTlsAuth = browserSessionCookie(directTlsSetCookie, directTlsRuntime.base_url);
|
|
3181
3371
|
const directTlsLogout = await directTlsRequest(directTlsRuntime, '/logout', {
|
|
3182
3372
|
method: 'POST',
|
|
3183
3373
|
headers: {
|
|
3184
|
-
Cookie:
|
|
3374
|
+
Cookie: directTlsAuth.cookie,
|
|
3185
3375
|
Origin: new URL(directTlsRuntime.base_url).origin
|
|
3186
3376
|
}
|
|
3187
3377
|
});
|
|
@@ -3189,8 +3379,9 @@ async function setupRegression() {
|
|
|
3189
3379
|
if (directTlsLogout.status !== 204 ||
|
|
3190
3380
|
!directTlsLogoutCookie.includes('Max-Age=0') ||
|
|
3191
3381
|
!directTlsLogoutCookie.includes('Secure')) {
|
|
3192
|
-
fail(`direct TLS logout did not expire a Secure cookie: status=${directTlsLogout.status}
|
|
3382
|
+
fail(`direct TLS logout did not expire a Secure cookie: status=${directTlsLogout.status}`);
|
|
3193
3383
|
}
|
|
3384
|
+
browserSessionCookie(directTlsLogoutCookie, directTlsRuntime.base_url, { expired: true });
|
|
3194
3385
|
await stopRuntime();
|
|
3195
3386
|
|
|
3196
3387
|
await assertWebWrapperParentSurvives();
|
|
@@ -3298,26 +3489,28 @@ async function setupRegression() {
|
|
|
3298
3489
|
});
|
|
3299
3490
|
if (exchange.status !== 302) fail(`browser-nav ?token did not exchange to a cookie redirect: ${exchange.status}`);
|
|
3300
3491
|
const setCookie = exchange.headers.get('set-cookie') || '';
|
|
3301
|
-
if (!setCookie.includes('
|
|
3302
|
-
fail(
|
|
3303
|
-
}
|
|
3304
|
-
if (setCookie.includes('Secure')) fail(
|
|
3305
|
-
const
|
|
3306
|
-
|
|
3307
|
-
const
|
|
3308
|
-
|
|
3309
|
-
|
|
3492
|
+
if (!setCookie.includes('HttpOnly') || !setCookie.includes('SameSite=Lax')) {
|
|
3493
|
+
fail('exchange did not set an HttpOnly SameSite cookie');
|
|
3494
|
+
}
|
|
3495
|
+
if (setCookie.includes('Secure')) fail('plaintext LAN exchange issued a Secure cookie');
|
|
3496
|
+
const browserAuth = browserSessionCookie(setCookie, baseUrl);
|
|
3497
|
+
// (b) API with the session cookie and explicitly selected project → 200
|
|
3498
|
+
const cookieProjectIdentity = await selectCookieProject(baseUrl, browserAuth.cookie);
|
|
3499
|
+
const withCookie = await fetch(`${baseUrl}/api/runtime`, {
|
|
3500
|
+
headers: { Cookie: browserAuth.cookie, 'X-HCC-API-Version': '2', 'X-HCC-Root-Identity': cookieProjectIdentity }
|
|
3501
|
+
});
|
|
3310
3502
|
if (!withCookie.ok) fail(`API with session cookie required auth: ${withCookie.status}`);
|
|
3311
3503
|
// (c) API with a bogus session cookie → 401
|
|
3312
|
-
const bogusCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie:
|
|
3504
|
+
const bogusCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: `${browserAuth.name}=bogus`, 'X-HCC-API-Version': '2' } });
|
|
3313
3505
|
if (bogusCookie.status !== 401) fail(`API with bogus session cookie did not 401: ${bogusCookie.status}`);
|
|
3314
3506
|
// (d) cookie-authenticated writes require an exact same-origin Origin header.
|
|
3315
3507
|
const crossOriginCookieWrite = await fetch(`${baseUrl}/api/csrf-probe`, {
|
|
3316
3508
|
method: 'POST',
|
|
3317
3509
|
headers: {
|
|
3318
|
-
Cookie:
|
|
3510
|
+
Cookie: browserAuth.cookie,
|
|
3319
3511
|
Origin: `http://127.0.0.1:${port + 1}`,
|
|
3320
3512
|
'X-HCC-API-Version': '2',
|
|
3513
|
+
'X-HCC-Root-Identity': cookieProjectIdentity,
|
|
3321
3514
|
'Content-Type': 'application/json'
|
|
3322
3515
|
},
|
|
3323
3516
|
body: '{}'
|
|
@@ -3332,9 +3525,10 @@ async function setupRegression() {
|
|
|
3332
3525
|
const sameOriginCookieWrite = await fetch(`${baseUrl}/api/csrf-probe`, {
|
|
3333
3526
|
method: 'POST',
|
|
3334
3527
|
headers: {
|
|
3335
|
-
Cookie:
|
|
3528
|
+
Cookie: browserAuth.cookie,
|
|
3336
3529
|
Origin: new URL(baseUrl).origin,
|
|
3337
3530
|
'X-HCC-API-Version': '2',
|
|
3531
|
+
'X-HCC-Root-Identity': cookieProjectIdentity,
|
|
3338
3532
|
'Content-Type': 'application/json'
|
|
3339
3533
|
},
|
|
3340
3534
|
body: '{}'
|
|
@@ -3359,23 +3553,25 @@ async function setupRegression() {
|
|
|
3359
3553
|
body: JSON.stringify({ token: cookieToken }),
|
|
3360
3554
|
redirect: 'manual'
|
|
3361
3555
|
});
|
|
3362
|
-
if (loginPost.status !== 302
|
|
3556
|
+
if (loginPost.status !== 302) {
|
|
3363
3557
|
fail(`POST /login did not issue a session cookie: ${loginPost.status}`);
|
|
3364
3558
|
}
|
|
3559
|
+
browserSessionCookie(loginPost.headers.get('set-cookie') || '', baseUrl);
|
|
3365
3560
|
|
|
3366
3561
|
// (h) logout revokes the opaque session and expires the browser cookie.
|
|
3367
3562
|
const logout = await fetch(`${baseUrl}/logout`, {
|
|
3368
3563
|
method: 'POST',
|
|
3369
3564
|
headers: {
|
|
3370
|
-
Cookie:
|
|
3565
|
+
Cookie: browserAuth.cookie,
|
|
3371
3566
|
Origin: new URL(baseUrl).origin
|
|
3372
3567
|
}
|
|
3373
3568
|
});
|
|
3374
3569
|
const logoutCookie = logout.headers.get('set-cookie') || '';
|
|
3375
|
-
if (logout.status !== 204 || !logoutCookie.includes('
|
|
3376
|
-
fail(`logout did not revoke and expire the session cookie: status=${logout.status}
|
|
3570
|
+
if (logout.status !== 204 || !logoutCookie.includes('Max-Age=0')) {
|
|
3571
|
+
fail(`logout did not revoke and expire the session cookie: status=${logout.status}`);
|
|
3377
3572
|
}
|
|
3378
|
-
|
|
3573
|
+
browserSessionCookie(logoutCookie, baseUrl, { expired: true });
|
|
3574
|
+
const revokedCookie = await fetch(`${baseUrl}/api/runtime`, { headers: { Cookie: browserAuth.cookie, 'X-HCC-API-Version': '2' } });
|
|
3379
3575
|
if (revokedCookie.status !== 401) fail(`logout left the old session cookie authorized: ${revokedCookie.status}`);
|
|
3380
3576
|
|
|
3381
3577
|
// (i) trusted loopback reverse-proxy headers mark issued and expired cookies
|
|
@@ -3404,14 +3600,24 @@ async function setupRegression() {
|
|
|
3404
3600
|
redirect: 'manual'
|
|
3405
3601
|
});
|
|
3406
3602
|
const proxySetCookie = proxyExchange.headers.get('set-cookie') || '';
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
fail(`trusted proxy login did not issue a Secure cookie: status=${proxyExchange.status} cookie=${proxySetCookie}`);
|
|
3603
|
+
if (proxyExchange.status !== 302 || !proxySetCookie.includes('Secure')) {
|
|
3604
|
+
fail(`trusted proxy login did not issue a Secure cookie: status=${proxyExchange.status}`);
|
|
3410
3605
|
}
|
|
3606
|
+
const proxyAuth = browserSessionCookie(proxySetCookie, proxyOrigin);
|
|
3607
|
+
const proxyProjectIdentity = await selectCookieProject(baseUrl, proxyAuth.cookie, root, {
|
|
3608
|
+
origin: proxyOrigin, headers: proxyHeaders
|
|
3609
|
+
});
|
|
3610
|
+
const withProxyCookie = await fetch(`${baseUrl}/api/runtime`, {
|
|
3611
|
+
headers: {
|
|
3612
|
+
...proxyHeaders, Cookie: proxyAuth.cookie, 'X-HCC-API-Version': '2',
|
|
3613
|
+
'X-HCC-Root-Identity': proxyProjectIdentity
|
|
3614
|
+
}
|
|
3615
|
+
});
|
|
3616
|
+
if (!withProxyCookie.ok) fail(`trusted proxy API rejected its session cookie: ${withProxyCookie.status}`);
|
|
3411
3617
|
const proxyLogout = await fetch(`${baseUrl}/logout`, {
|
|
3412
3618
|
method: 'POST',
|
|
3413
3619
|
headers: {
|
|
3414
|
-
Cookie:
|
|
3620
|
+
Cookie: proxyAuth.cookie,
|
|
3415
3621
|
Origin: proxyOrigin,
|
|
3416
3622
|
'X-Forwarded-Host': 'public.example.test:9443',
|
|
3417
3623
|
'X-Forwarded-Proto': 'https'
|
|
@@ -3419,9 +3625,12 @@ async function setupRegression() {
|
|
|
3419
3625
|
});
|
|
3420
3626
|
const proxyLogoutCookie = proxyLogout.headers.get('set-cookie') || '';
|
|
3421
3627
|
if (proxyLogout.status !== 204 || !proxyLogoutCookie.includes('Max-Age=0') || !proxyLogoutCookie.includes('Secure')) {
|
|
3422
|
-
fail(`trusted proxy logout did not expire a Secure cookie: status=${proxyLogout.status}
|
|
3628
|
+
fail(`trusted proxy logout did not expire a Secure cookie: status=${proxyLogout.status}`);
|
|
3423
3629
|
}
|
|
3424
|
-
|
|
3630
|
+
browserSessionCookie(proxyLogoutCookie, proxyOrigin, { expired: true });
|
|
3631
|
+
const revokedProxyCookie = await fetch(`${baseUrl}/api/runtime`, {
|
|
3632
|
+
headers: { ...proxyHeaders, Cookie: proxyAuth.cookie, 'X-HCC-API-Version': '2' }
|
|
3633
|
+
});
|
|
3425
3634
|
if (revokedProxyCookie.status !== 401) fail(`trusted proxy logout left the old cookie authorized: ${revokedProxyCookie.status}`);
|
|
3426
3635
|
|
|
3427
3636
|
// A forwarded authority other than the pinned public origin is untrusted.
|
|
@@ -3436,7 +3645,7 @@ async function setupRegression() {
|
|
|
3436
3645
|
});
|
|
3437
3646
|
const defaultPortProxySetCookie = defaultPortProxyExchange.headers.get('set-cookie') || '';
|
|
3438
3647
|
if (defaultPortProxyExchange.status !== 403 || defaultPortProxySetCookie) {
|
|
3439
|
-
fail(`unpinned proxy authority received a session: status=${defaultPortProxyExchange.status}
|
|
3648
|
+
fail(`unpinned proxy authority received a session: status=${defaultPortProxyExchange.status} setCookie=${Boolean(defaultPortProxySetCookie)}`);
|
|
3440
3649
|
}
|
|
3441
3650
|
const unpinnedProxyLogin = await fetch(`${baseUrl}/login`, {
|
|
3442
3651
|
method: 'POST',
|
|
@@ -3461,7 +3670,7 @@ async function setupRegression() {
|
|
|
3461
3670
|
});
|
|
3462
3671
|
const spoofedLanCookie = spoofedLanExchange.headers.get('set-cookie') || '';
|
|
3463
3672
|
if (spoofedLanExchange.status !== 403 || spoofedLanCookie) {
|
|
3464
|
-
fail(`non-loopback forwarded spoof received a session: status=${spoofedLanExchange.status}
|
|
3673
|
+
fail(`non-loopback forwarded spoof received a session: status=${spoofedLanExchange.status} setCookie=${Boolean(spoofedLanCookie)}`);
|
|
3465
3674
|
}
|
|
3466
3675
|
} else {
|
|
3467
3676
|
log('non-loopback forwarded spoof: no non-loopback IPv4 interface; focused boundary coverage retained');
|
|
@@ -4606,16 +4815,24 @@ async function multiProjectWebWorkflow() {
|
|
|
4606
4815
|
fail(`detected API did not return liveness metadata:\n${JSON.stringify(detectedJson, null, 2)}`);
|
|
4607
4816
|
}
|
|
4608
4817
|
|
|
4609
|
-
const htmlResponse = await fetch(currentRuntimeUrl('/'));
|
|
4610
|
-
const
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
}
|
|
4618
|
-
|
|
4818
|
+
const htmlResponse = await withFixtureHttpDiagnostic('/', {}, () => fetch(currentRuntimeUrl('/')));
|
|
4819
|
+
const pageHtml = await htmlResponse.text();
|
|
4820
|
+
if (!pageHtml.includes('type="module" src="/assets/web/browser/core.mjs"')) fail('web page missing ESM bootstrap');
|
|
4821
|
+
const moduleSources = [];
|
|
4822
|
+
for (const asset of ['browser/core.mjs', 'ui-agent-start.mjs']) {
|
|
4823
|
+
const response = await runtimeFetch('/assets/web/' + asset);
|
|
4824
|
+
if (!response.ok || !response.headers.get('content-type')?.includes('javascript')) fail('web ESM asset did not load: ' + asset);
|
|
4825
|
+
moduleSources.push(await response.text());
|
|
4826
|
+
}
|
|
4827
|
+
const html = pageHtml + '\n' + moduleSources.join('\n');
|
|
4828
|
+
const startFormHtml = html.match(/<form\b[^>]*\bid="startForm"[^>]*>[\s\S]*?<\/form>/)?.[0];
|
|
4829
|
+
if (!startFormHtml) fail('web form missing simplified session form');
|
|
4830
|
+
// A current-directory label in an approval or session details is valid;
|
|
4831
|
+
// obsolete creation inputs must be checked inside the creation form.
|
|
4832
|
+
for (const forbidden of ['Alias optional', 'Role tag', 'Command<input']) {
|
|
4833
|
+
if (startFormHtml.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
|
|
4834
|
+
}
|
|
4835
|
+
for (const forbidden of ['commandbar', 'lineInput', 'Send text to active terminal']) {
|
|
4619
4836
|
if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
|
|
4620
4837
|
}
|
|
4621
4838
|
for (const expected of [
|
|
@@ -4623,6 +4840,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4623
4840
|
'id="projectPath"',
|
|
4624
4841
|
'id="addProjectBtn"',
|
|
4625
4842
|
'id="startForm"',
|
|
4843
|
+
'id="agentCwd"',
|
|
4626
4844
|
'id="kind"',
|
|
4627
4845
|
'id="sessionKindFilter"',
|
|
4628
4846
|
'id="sessions"'
|
|
@@ -4666,7 +4884,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4666
4884
|
!html.includes("fetch('/logout', { method: 'POST', headers })")) {
|
|
4667
4885
|
fail('web UI missing logout control or session revocation request');
|
|
4668
4886
|
}
|
|
4669
|
-
if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode === 'resume'")) {
|
|
4887
|
+
if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode.value === 'resume'")) {
|
|
4670
4888
|
fail('web form missing provider resume controls');
|
|
4671
4889
|
}
|
|
4672
4890
|
for (const expected of [
|
|
@@ -4921,7 +5139,7 @@ async function multiProjectWebWorkflow() {
|
|
|
4921
5139
|
fail(`same-origin cookie administrator create failed or leaked its action token:\n${JSON.stringify(cookieCreated, null, 2)}`);
|
|
4922
5140
|
}
|
|
4923
5141
|
|
|
4924
|
-
const cookieAdminWs = await openCookieTerminalWebSocket(cookieAdminId, cookieAdmin.
|
|
5142
|
+
const cookieAdminWs = await openCookieTerminalWebSocket(cookieAdminId, cookieAdmin.cookie, { root }, cookieAdmin.projectIdentity);
|
|
4925
5143
|
try {
|
|
4926
5144
|
if (!cookieAdminWs.hccActionToken) fail('cookie administrator terminal snapshot omitted its action token');
|
|
4927
5145
|
const inputRoute = `/api/sessions/${encodeURIComponent(cookieAdminId)}/input`;
|
|
@@ -5733,7 +5951,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5733
5951
|
|
|
5734
5952
|
const liveId = `gc-live-external-${testId}`;
|
|
5735
5953
|
const liveFiles = ['out', 'in', 'resize', 'meta'].map((suffix) => path.join(rootBufs, `${liveId}.${suffix}`));
|
|
5736
|
-
for (const file of liveFiles.slice(0, 3)) fs.writeFileSync(file, file.endsWith('.out') ? 'live\n' : '');
|
|
5954
|
+
for (const file of liveFiles.slice(0, 3)) fs.writeFileSync(file, file.endsWith('.out') ? 'live\n' : '', { mode: 0o600 });
|
|
5737
5955
|
const identity = inspectProcessIdentity(process.pid).identity;
|
|
5738
5956
|
fs.writeFileSync(liveFiles[3], JSON.stringify({
|
|
5739
5957
|
id: liveId,
|
|
@@ -5747,7 +5965,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5747
5965
|
wrapper_identity: identity,
|
|
5748
5966
|
cols: 120,
|
|
5749
5967
|
rows: 40
|
|
5750
|
-
}));
|
|
5968
|
+
}), { mode: 0o600 });
|
|
5751
5969
|
await waitFor(async () => {
|
|
5752
5970
|
const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
5753
5971
|
return (data.sessions || []).some((session) => session.id === liveId);
|
|
@@ -5756,8 +5974,8 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5756
5974
|
|
|
5757
5975
|
const legacyId = `gc-legacy-${testId}`;
|
|
5758
5976
|
const legacyFiles = ['out', 'in', 'resize', 'meta'].map((suffix) => path.join(rootBufs, `${legacyId}.${suffix}`));
|
|
5759
|
-
for (const file of legacyFiles.slice(0, 3)) fs.writeFileSync(file, '');
|
|
5760
|
-
fs.writeFileSync(legacyFiles[3], JSON.stringify({ id: legacyId, pid: process.pid, wrapper_pid: process.pid }));
|
|
5977
|
+
for (const file of legacyFiles.slice(0, 3)) fs.writeFileSync(file, '', { mode: 0o600 });
|
|
5978
|
+
fs.writeFileSync(legacyFiles[3], JSON.stringify({ id: legacyId, pid: process.pid, wrapper_pid: process.pid }), { mode: 0o600 });
|
|
5761
5979
|
for (const file of legacyFiles) fs.utimesSync(file, oldTime, oldTime);
|
|
5762
5980
|
await waitFor(async () => {
|
|
5763
5981
|
const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
@@ -5767,7 +5985,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5767
5985
|
const siblingLiveId = `gc-sibling-external-${testId}`;
|
|
5768
5986
|
const siblingLiveFiles = ['out', 'in', 'resize', 'meta']
|
|
5769
5987
|
.map((suffix) => path.join(siblingBufs, `${siblingLiveId}.${suffix}`));
|
|
5770
|
-
for (const file of siblingLiveFiles.slice(0, 3)) fs.writeFileSync(file, '');
|
|
5988
|
+
for (const file of siblingLiveFiles.slice(0, 3)) fs.writeFileSync(file, '', { mode: 0o600 });
|
|
5771
5989
|
fs.writeFileSync(siblingLiveFiles[3], JSON.stringify({
|
|
5772
5990
|
id: siblingLiveId,
|
|
5773
5991
|
kind: 'shell',
|
|
@@ -5780,7 +5998,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5780
5998
|
wrapper_identity: identity,
|
|
5781
5999
|
cols: 120,
|
|
5782
6000
|
rows: 40
|
|
5783
|
-
}));
|
|
6001
|
+
}), { mode: 0o600 });
|
|
5784
6002
|
await waitFor(async () => {
|
|
5785
6003
|
const data = await (await runtimeFetch('/api/sessions', {}, { root: secondProjectRoot })).json();
|
|
5786
6004
|
return (data.sessions || []).some((session) => session.id === siblingLiveId);
|
|
@@ -6079,7 +6297,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
6079
6297
|
base_url: server?.baseUrl || 'http://127.0.0.1:1',
|
|
6080
6298
|
token: 'regression-token'
|
|
6081
6299
|
};
|
|
6082
|
-
fs.writeFileSync(path.join(isolatedRoot, '.hello-cc', 'runtime.json'), JSON.stringify(pointer));
|
|
6300
|
+
fs.writeFileSync(path.join(isolatedRoot, '.hello-cc', 'runtime.json'), JSON.stringify(pointer), { mode: 0o600 });
|
|
6083
6301
|
const gc = run(process.execPath, [hccBin, '--root', isolatedRoot, '--json', 'gc', '--older-than', '0', '--yes'], { env: isolatedEnv });
|
|
6084
6302
|
const payload = JSON.parse(gc);
|
|
6085
6303
|
if (!fs.existsSync(orphan) || Number(payload.data?.deferred_buf_files || 0) < 1) {
|
|
@@ -6437,13 +6655,53 @@ async function shimTmuxWorkflow() {
|
|
|
6437
6655
|
cwd: root,
|
|
6438
6656
|
env: { ...shimEnv, HCC_REG_VALUE: 'shim-second' }
|
|
6439
6657
|
});
|
|
6440
|
-
parsePane(restarted);
|
|
6658
|
+
const restartedPane = parsePane(restarted);
|
|
6441
6659
|
const secondEnvFile = path.join(outDir, 'shim-env-second');
|
|
6442
6660
|
hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${secondEnvFile}`]);
|
|
6443
6661
|
await waitForFile(secondEnvFile, 'shim-second', 'shim env restart');
|
|
6444
6662
|
const reentryFile = path.join(outDir, 'shim-reentry');
|
|
6445
|
-
|
|
6446
|
-
|
|
6663
|
+
const reentryTrace = path.join(outDir, 'shim-reentry-stages');
|
|
6664
|
+
const reentryStatus = path.join(outDir, 'shim-reentry-status');
|
|
6665
|
+
const reentryProvider = path.join(outDir, 'shim-reentry-provider');
|
|
6666
|
+
const reentryProbe = pathToFileURL(path.join(repoRoot, 'scripts', 'shim-reentry-probe.mjs')).href;
|
|
6667
|
+
const reentryEventId = withMeshDb(db => db.prepare('SELECT MAX(id) AS id FROM events').get().id || 0);
|
|
6668
|
+
const reentryStarted = Date.now();
|
|
6669
|
+
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)}`]);
|
|
6670
|
+
await waitForFileContent(reentryFile, 'fake-claude --resume shim-regression-session', 'shim tmux pane re-entry', {
|
|
6671
|
+
ownedReentryOutput: true,
|
|
6672
|
+
diagnostics: () => {
|
|
6673
|
+
// Query only this fixture's pane and fixed event types. Full terminal
|
|
6674
|
+
// captures, process arguments, request bodies and environment are excluded.
|
|
6675
|
+
const paneResult = spawnSync('tmux', ['display-message', '-p', '-t', restartedPane,
|
|
6676
|
+
'#{pane_id}|#{pane_dead}|#{pane_current_command}'], {
|
|
6677
|
+
env, encoding: 'utf8', timeout: 1000, maxBuffer: 1024, stdio: ['ignore', 'pipe', 'ignore']
|
|
6678
|
+
});
|
|
6679
|
+
const paneParts = (paneResult.stdout || '').trim().split('|');
|
|
6680
|
+
const pane = {
|
|
6681
|
+
found: paneResult.status === 0 && /^%\d+$/.test(paneParts[0] || ''),
|
|
6682
|
+
dead: paneParts[1] === '1',
|
|
6683
|
+
command: /^[A-Za-z0-9_.-]{1,40}$/.test(paneParts[2] || '') ? paneParts[2] : 'unknown'
|
|
6684
|
+
};
|
|
6685
|
+
let status = [];
|
|
6686
|
+
try { status = fs.readFileSync(reentryStatus, 'utf8').slice(0, 32).trim().split('\n'); } catch {}
|
|
6687
|
+
let events = [];
|
|
6688
|
+
let db;
|
|
6689
|
+
try {
|
|
6690
|
+
db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { timeout: 250, readOnly: true });
|
|
6691
|
+
events = db.prepare(`SELECT type, created_at FROM events WHERE id > ? AND actor = ?
|
|
6692
|
+
AND type IN ('peer.attach.requested', 'tmux.session.attached', 'tmux.session.detached', 'tmux.session.exited')
|
|
6693
|
+
ORDER BY id DESC LIMIT 8`).all(reentryEventId, peer);
|
|
6694
|
+
} catch { events = [{ state: 'unavailable' }]; }
|
|
6695
|
+
finally { if (db) db.close(); }
|
|
6696
|
+
return {
|
|
6697
|
+
elapsed_ms: Date.now() - reentryStarted,
|
|
6698
|
+
shell_started: status[0] === 'started',
|
|
6699
|
+
shell_exit: /^\d{1,3}$/.test(status[1] || '') ? Number(status[1]) : null,
|
|
6700
|
+
provider_entered: fs.existsSync(reentryProvider),
|
|
6701
|
+
pane, stages: readReentryTrace(reentryTrace), events
|
|
6702
|
+
};
|
|
6703
|
+
}
|
|
6704
|
+
});
|
|
6447
6705
|
hcc(['peer', 'stop', peer]);
|
|
6448
6706
|
|
|
6449
6707
|
const exitedResume = 'shim-exited-session';
|
|
@@ -6563,15 +6821,18 @@ async function askBroadcastWorkflow() {
|
|
|
6563
6821
|
}
|
|
6564
6822
|
await waitForFile(dispatchFile, 'DISPATCH_OK', 'task dispatch injection');
|
|
6565
6823
|
const broadcastFile = path.join(outDir, 'broadcast-ok');
|
|
6566
|
-
|
|
6824
|
+
hccJson(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject'], { diagnosticOperation: 'broadcast' });
|
|
6567
6825
|
await waitForFile(broadcastFile, 'BROADCAST_OK', 'broadcast injection');
|
|
6568
6826
|
}
|
|
6569
6827
|
|
|
6570
6828
|
async function downGcPackWorkflow() {
|
|
6571
6829
|
log('[9/13] down/gc/pack');
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6830
|
+
const peerStops = ['shell-a', 'shell-b'].map(peer => {
|
|
6831
|
+
const started = performance.now();
|
|
6832
|
+
const result = fixtureDownResult(hccMaybe(['--json', 'peer', 'stop', peer]));
|
|
6833
|
+
return { ...result, durationMs: Math.round(performance.now() - started) };
|
|
6834
|
+
});
|
|
6835
|
+
await stopRuntime({ peerStops });
|
|
6575
6836
|
await waitFor(() => !fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), 'runtime cleanup', 5000);
|
|
6576
6837
|
hcc(['gc', '--older-than', '0', '--yes']);
|
|
6577
6838
|
const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
|
|
@@ -6820,7 +7081,7 @@ async function syntaxAndHelp() {
|
|
|
6820
7081
|
const integrationShimScriptSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'shims', 'script.mjs'), 'utf8');
|
|
6821
7082
|
const shellPathSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shell-path.mjs'), 'utf8');
|
|
6822
7083
|
const webPeerActionsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'peer-actions.mjs'), 'utf8');
|
|
6823
|
-
const webUiTemplateSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web',
|
|
7084
|
+
const webUiTemplateSource = ['ui-template.mjs', 'browser/core.mjs'].map(name => fs.readFileSync(path.join(repoRoot, 'lib', 'web', name), 'utf8')).join('\n');
|
|
6824
7085
|
const tmuxSafetySource = fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'tmux-safety.mjs'), 'utf8');
|
|
6825
7086
|
// cmdWeb moved whole to lib/web/runtime-main.mjs; subsystem modules extend it
|
|
6826
7087
|
const cmdWebSource = [
|
|
@@ -6884,15 +7145,15 @@ async function syntaxAndHelp() {
|
|
|
6884
7145
|
'sameResolvedPath(global.root, ctx.root)',
|
|
6885
7146
|
'sameResolvedPath(global.db, ctx.dbPath)',
|
|
6886
7147
|
'async function stopOrphanWebRuntimes(',
|
|
6887
|
-
'await stopOrphanWebRuntimes(
|
|
6888
|
-
'await stopOrphanWebRuntimes(
|
|
7148
|
+
'await stopOrphanWebRuntimes(boundCtx, existing.pid || null);',
|
|
7149
|
+
'await stopOrphanWebRuntimes(boundCtx);'
|
|
6889
7150
|
]) {
|
|
6890
7151
|
if (!webStartupSource.includes(expected)) fail(`web startup guard missing: ${expected}`);
|
|
6891
7152
|
}
|
|
6892
7153
|
for (const expected of [
|
|
6893
7154
|
'function listTmuxPanesOnce()',
|
|
6894
7155
|
'let autoAttachScanInFlight = false;',
|
|
6895
|
-
'if (autoAttachScanInFlight) return;',
|
|
7156
|
+
'if (isStopping() || autoAttachScanInFlight) return;',
|
|
6896
7157
|
'const paneByPid = new Map();',
|
|
6897
7158
|
'const attached = attachedTmuxState(ctx, db);',
|
|
6898
7159
|
'function killOldTmuxForRebind(',
|
|
@@ -7252,7 +7513,7 @@ async function syntaxAndHelp() {
|
|
|
7252
7513
|
!hccSource.includes("} from '../web/http.mjs'") ||
|
|
7253
7514
|
!hccSource.includes("import * as webUiTemplate from '../web/ui-template.mjs'") ||
|
|
7254
7515
|
!hccSource.includes('const VERSION = PACKAGE_META.version') ||
|
|
7255
|
-
!hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
|
|
7516
|
+
!hccSource.includes('writeGuidanceForRoot(ctx.root, { expectedSnapshot: ctx.initialRootIdentity })')) {
|
|
7256
7517
|
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');
|
|
7257
7518
|
}
|
|
7258
7519
|
for (const expected of [
|
|
@@ -7740,7 +8001,7 @@ async function syntaxAndHelp() {
|
|
|
7740
8001
|
fail('runtime state clearRuntime did not remove the reused-pid global pointer');
|
|
7741
8002
|
}
|
|
7742
8003
|
|
|
7743
|
-
fs.writeFileSync(globalRuntimeFile, '{bad');
|
|
8004
|
+
fs.writeFileSync(globalRuntimeFile, '{bad', { mode: 0o600 });
|
|
7744
8005
|
if (runtimeState.readGlobalRuntimeFile() !== null) {
|
|
7745
8006
|
fail('runtime state readGlobalRuntimeFile did not reject invalid JSON');
|
|
7746
8007
|
}
|
|
@@ -9688,7 +9949,7 @@ async function syntaxAndHelp() {
|
|
|
9688
9949
|
process.env.HOME = tlsHome;
|
|
9689
9950
|
const webTls = await import(path.join(repoRoot, 'lib', 'web', 'tls.mjs'));
|
|
9690
9951
|
tlsCredentials = webTls.ensureSelfSignedCert();
|
|
9691
|
-
const tlsDir = path.join(tlsHome, '.hello-cc', 'tls');
|
|
9952
|
+
const tlsDir = path.join(fs.realpathSync(tlsHome), '.hello-cc', 'tls');
|
|
9692
9953
|
const currentPointerPath = path.join(tlsDir, 'current.json');
|
|
9693
9954
|
const currentGeneration = JSON.parse(fs.readFileSync(currentPointerPath, 'utf8')).generation;
|
|
9694
9955
|
const generationNames = {
|
|
@@ -9724,27 +9985,28 @@ async function syntaxAndHelp() {
|
|
|
9724
9985
|
}
|
|
9725
9986
|
const originalTlsCert = tlsCredentials.cert;
|
|
9726
9987
|
const originalTlsCertPath = tlsCredentials.certPath;
|
|
9727
|
-
const
|
|
9728
|
-
let
|
|
9988
|
+
const originalLstatSync = fs.lstatSync;
|
|
9989
|
+
let pointerChecks = 0;
|
|
9729
9990
|
let pointerSwitchedBeforeDelete = false;
|
|
9730
|
-
fs.
|
|
9991
|
+
fs.lstatSync = (file, ...args) => {
|
|
9731
9992
|
if (path.resolve(String(file)) === path.resolve(currentPointerPath)) {
|
|
9732
|
-
|
|
9733
|
-
//
|
|
9734
|
-
//
|
|
9735
|
-
|
|
9993
|
+
pointerChecks += 1;
|
|
9994
|
+
// The private reader lstat-checks before and after each descriptor
|
|
9995
|
+
// read. Checks 1-2 validate the old current; 3-4 start cleanup; check
|
|
9996
|
+
// 5 is the deletion-time guard for the stale switched-current row.
|
|
9997
|
+
if (pointerChecks === 5) {
|
|
9736
9998
|
const nextPointerPath = path.join(tlsDir, `.current-regression-${testId}.tmp`);
|
|
9737
9999
|
fs.writeFileSync(nextPointerPath, `${JSON.stringify({ generation: generationNames.switchedCurrent })}\n`, { mode: 0o600 });
|
|
9738
10000
|
fs.renameSync(nextPointerPath, currentPointerPath);
|
|
9739
10001
|
pointerSwitchedBeforeDelete = true;
|
|
9740
10002
|
}
|
|
9741
10003
|
}
|
|
9742
|
-
return
|
|
10004
|
+
return originalLstatSync(file, ...args);
|
|
9743
10005
|
};
|
|
9744
10006
|
try {
|
|
9745
10007
|
tlsCredentials = webTls.ensureSelfSignedCert();
|
|
9746
10008
|
} finally {
|
|
9747
|
-
fs.
|
|
10009
|
+
fs.lstatSync = originalLstatSync;
|
|
9748
10010
|
}
|
|
9749
10011
|
const publishedGeneration = JSON.parse(fs.readFileSync(currentPointerPath, 'utf8')).generation;
|
|
9750
10012
|
const remainingGenerations = fs.readdirSync(tlsDir, { withFileTypes: true })
|
|
@@ -9760,14 +10022,14 @@ async function syntaxAndHelp() {
|
|
|
9760
10022
|
].sort();
|
|
9761
10023
|
if (tlsCredentials.cert !== originalTlsCert ||
|
|
9762
10024
|
tlsCredentials.certPath !== originalTlsCertPath ||
|
|
9763
|
-
!pointerSwitchedBeforeDelete ||
|
|
10025
|
+
!pointerSwitchedBeforeDelete || pointerChecks < 6 ||
|
|
9764
10026
|
publishedGeneration !== generationNames.switchedCurrent ||
|
|
9765
10027
|
!fs.existsSync(path.join(tlsDir, generationNames.initialCurrent, '.published')) ||
|
|
9766
10028
|
!fs.existsSync(path.join(tlsDir, generationNames.switchedCurrent, '.published')) ||
|
|
9767
10029
|
!fs.existsSync(path.join(tlsDir, generationNames.activeCreating, '.creating')) ||
|
|
9768
10030
|
fs.existsSync(path.join(tlsDir, generationNames.deadCreating)) ||
|
|
9769
10031
|
JSON.stringify(remainingGenerations) !== JSON.stringify(expectedRemainingGenerations)) {
|
|
9770
|
-
fail(`TLS generation cleanup violated current/previous/candidate lifecycle protection:\n${JSON.stringify({ generationNames,
|
|
10032
|
+
fail(`TLS generation cleanup violated current/previous/candidate lifecycle protection:\n${JSON.stringify({ generationNames, pointerChecks, pointerSwitchedBeforeDelete, publishedGeneration, remainingGenerations }, null, 2)}`);
|
|
9771
10033
|
}
|
|
9772
10034
|
} finally {
|
|
9773
10035
|
if (savedTlsHome === undefined) delete process.env.HOME;
|
|
@@ -10328,9 +10590,9 @@ async function processEvidenceWorkflow() {
|
|
|
10328
10590
|
const legacyProcess = spawn('sleep', ['30'], { stdio: 'ignore' });
|
|
10329
10591
|
const legacyFiles = ['out', 'in', 'resize', 'meta']
|
|
10330
10592
|
.map((suffix) => path.join(externalDir, `${legacyExternalId}.${suffix}`));
|
|
10331
|
-
fs.writeFileSync(legacyFiles[0], 'legacy external\n');
|
|
10332
|
-
fs.writeFileSync(legacyFiles[1], '');
|
|
10333
|
-
fs.writeFileSync(legacyFiles[2], '');
|
|
10593
|
+
fs.writeFileSync(legacyFiles[0], 'legacy external\n', { mode: 0o600 });
|
|
10594
|
+
fs.writeFileSync(legacyFiles[1], '', { mode: 0o600 });
|
|
10595
|
+
fs.writeFileSync(legacyFiles[2], '', { mode: 0o600 });
|
|
10334
10596
|
fs.writeFileSync(legacyFiles[3], JSON.stringify({
|
|
10335
10597
|
id: legacyExternalId,
|
|
10336
10598
|
kind: 'shell',
|
|
@@ -10341,7 +10603,7 @@ async function processEvidenceWorkflow() {
|
|
|
10341
10603
|
wrapper_pid: legacyProcess.pid,
|
|
10342
10604
|
cols: 120,
|
|
10343
10605
|
rows: 40
|
|
10344
|
-
}));
|
|
10606
|
+
}), { mode: 0o600 });
|
|
10345
10607
|
await waitFor(async () => {
|
|
10346
10608
|
const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
10347
10609
|
return (data.sessions || []).some((session) => session.id === legacyExternalId);
|
|
@@ -11387,7 +11649,7 @@ async function main() {
|
|
|
11387
11649
|
log('FULL_REGRESSION_OK');
|
|
11388
11650
|
}
|
|
11389
11651
|
|
|
11390
|
-
if (
|
|
11652
|
+
if (isRegressionEntry()) main().catch((err) => {
|
|
11391
11653
|
const failure = fixtureFailureDiagnostic(err);
|
|
11392
11654
|
const diagnostic = collectRuntimeDiagnostic({ failure, stop: lastStopDiagnostic });
|
|
11393
11655
|
process.stderr.write(`[regression failure diagnostic] ${diagnostic}\n`);
|