@logicseek/hello-cc 1.1.0-rc.9 → 1.1.1
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 +96 -0
- package/README.md +76 -21
- package/README.zh-CN.md +45 -11
- package/assets/screenshots/web-console.png +0 -0
- package/docs/commands.md +5 -0
- package/docs/commands.zh-CN.md +5 -0
- package/docs/native.md +40 -0
- package/docs/native.zh-CN.md +34 -0
- package/lib/core/peers/tmux-safety.mjs +5 -0
- package/lib/integrations/native/jsonrpc.mjs +45 -11
- package/lib/runtime/native/cli-submissions.mjs +9 -1
- package/lib/runtime/native/service.mjs +3 -0
- package/lib/shared/file-lock.mjs +6 -2
- package/lib/shared/socket-lock-diagnostics.mjs +31 -0
- package/lib/shared/socket-lock-worker.mjs +7 -5
- package/lib/web/context-files.mjs +2 -89
- package/lib/web/http-routes.mjs +4 -4
- package/lib/web/native-sessions.mjs +10 -1
- package/lib/web/project-files.mjs +24 -35
- package/package.json +1 -1
- package/scripts/claude-fork-acceptance.mjs +202 -0
- package/scripts/regression.mjs +65 -11
- package/scripts/web-native-retry-checks.mjs +28 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Actual SDK, CLI subprocess and production adapter; optional local Messages
|
|
2
|
+
// fixture isolates transcript/fork behavior from model recall and credentials.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
12
|
+
import { createClaudeAdapter } from '../lib/integrations/native/claude.mjs';
|
|
13
|
+
import { redactSecrets } from '../lib/shared/redact.mjs';
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
if (args.includes('--help') || (!args.includes('--fixture') && !args.includes('--run-live'))) {
|
|
17
|
+
console.log('Usage: node scripts/claude-fork-acceptance.mjs --fixture|--run-live [--claude-package DIR] [--output FILE]');
|
|
18
|
+
console.log('--fixture uses a local Messages server, no model or credentials. --run-live copies existing model routing/auth into a private provider home and consumes quota.');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
const flags = new Set(['--fixture', '--run-live']), values = new Set(['--claude-package', '--output']);
|
|
22
|
+
const options = new Map();
|
|
23
|
+
for (let index = 0; index < args.length; index++) {
|
|
24
|
+
const name = args[index];
|
|
25
|
+
assert.ok(!options.has(name) && (flags.has(name) || values.has(name)), 'Unknown or repeated option');
|
|
26
|
+
if (flags.has(name)) options.set(name, true);
|
|
27
|
+
else { assert.ok(args[index + 1] && !args[index + 1].startsWith('--'), 'Missing option value'); options.set(name, args[++index]); }
|
|
28
|
+
}
|
|
29
|
+
assert.notEqual(Boolean(options.get('--fixture')), Boolean(options.get('--run-live')), 'Select exactly one mode');
|
|
30
|
+
assert.ok(['linux', 'darwin'].includes(process.platform), 'Run inside Linux, macOS or WSL');
|
|
31
|
+
const live = Boolean(options.get('--run-live'));
|
|
32
|
+
const repo = fileURLToPath(new URL('..', import.meta.url));
|
|
33
|
+
const output = path.resolve(options.get('--output') || path.join(os.tmpdir(), 'hcc-claude-fork-' + randomUUID() + '.json'));
|
|
34
|
+
const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-claude-fork-')));
|
|
35
|
+
fs.chmodSync(sandbox, 0o700);
|
|
36
|
+
const root = path.join(sandbox, 'project'), home = path.join(sandbox, 'home'), providerHome = path.join(home, '.claude');
|
|
37
|
+
for (const directory of [root, home, providerHome]) fs.mkdirSync(directory, { mode: 0o700 });
|
|
38
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
39
|
+
const digest = file => hash(fs.readFileSync(file));
|
|
40
|
+
const sourceFiles = Object.fromEntries(['scripts/claude-fork-acceptance.mjs', 'lib/integrations/native/claude.mjs',
|
|
41
|
+
'lib/integrations/native/interactions.mjs', 'lib/process/selected-cwd-identity.mjs'].map(name => [name, digest(path.join(repo, name))]));
|
|
42
|
+
const originalEnv = { HOME: process.env.HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR };
|
|
43
|
+
const settings = path.join(originalEnv.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json');
|
|
44
|
+
const protectedBefore = fs.existsSync(settings) ? digest(settings) : null;
|
|
45
|
+
const report = { schemaVersion: 1, startedAt: new Date().toISOString(), sourceFiles,
|
|
46
|
+
mode: live ? 'real configured model' : 'deterministic local Messages fixture; actual SDK and CLI subprocess',
|
|
47
|
+
modelCallsRequested: live, realModelCalls: false, queryInjected: false, checks: [], snapshots: [], requests: [], cleanup: {}, success: false,
|
|
48
|
+
limitations: ['Adapter/SDK transcript and recall acceptance; native service ownership guards are covered separately.',
|
|
49
|
+
'A successful routed model call establishes only the model actually reported, not Anthropic model acceptance.'] };
|
|
50
|
+
const flush = () => { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(redactSecrets(report), null, 2) + '\n', { mode: 0o600 }); };
|
|
51
|
+
const check = name => { report.checks.push(name); console.log('PASS ' + name); flush(); };
|
|
52
|
+
const adapters = [], observations = new Map();
|
|
53
|
+
let server, sdk;
|
|
54
|
+
function adapter(name) {
|
|
55
|
+
const events = [];
|
|
56
|
+
const value = createClaudeAdapter({ cwd: root, env: { ...process.env, HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1' },
|
|
57
|
+
executorId: randomUUID(), timeoutMs: 15000,
|
|
58
|
+
onEvent(event) {
|
|
59
|
+
if (event.type === 'metadata') report.reportedModel = event.runtimeMetadata?.model || report.reportedModel;
|
|
60
|
+
if (event.type === 'completed') events.push({ type: event.type, turnId: event.turnId, status: event.status, text: event.text });
|
|
61
|
+
} });
|
|
62
|
+
adapters.push({ name, value }); observations.set(value, events); return value;
|
|
63
|
+
}
|
|
64
|
+
async function turn(value, prompt, expected) {
|
|
65
|
+
const sent = await value.send({ text: prompt, submissionId: randomUUID() });
|
|
66
|
+
const deadline = Date.now() + 90000;
|
|
67
|
+
let completion;
|
|
68
|
+
while (!(completion = observations.get(value).find(event => event.turnId === sent.turnId)) && Date.now() < deadline) {
|
|
69
|
+
const state = value.snapshot();
|
|
70
|
+
assert.equal(state.pendingApprovals?.length || 0, 0, 'Nonce-only fork test must never approve tools');
|
|
71
|
+
assert.ok(!state.error, 'Unexpected SDK error during fork acceptance');
|
|
72
|
+
await delay(50);
|
|
73
|
+
}
|
|
74
|
+
assert.ok(completion, 'Fork acceptance turn timed out');
|
|
75
|
+
if (live) report.realModelCalls = true;
|
|
76
|
+
assert.equal(completion.status, 'completed');
|
|
77
|
+
// Persist a non-sensitive actual/expected digest even when the assertion fails.
|
|
78
|
+
report.lastTurn = { sessionId: value.snapshot().sessionId, turnId: sent.turnId,
|
|
79
|
+
actualSha256: hash(completion.text || ''), expectedSha256: hash(expected), matches: completion.text?.trim() === expected };
|
|
80
|
+
flush(); assert.equal(completion.text?.trim(), expected);
|
|
81
|
+
}
|
|
82
|
+
async function history(sessionId, label) {
|
|
83
|
+
const messages = (await sdk.getSessionMessages(sessionId, { dir: root })).filter(message => ['user', 'assistant'].includes(message.type));
|
|
84
|
+
const snapshot = { label, sessionId, messages: messages.length,
|
|
85
|
+
contentHashes: messages.map(message => hash(JSON.stringify({ type: message.type, message: message.message }))),
|
|
86
|
+
messageIds: messages.map(message => message.uuid) };
|
|
87
|
+
report.snapshots.push(snapshot); flush(); return snapshot;
|
|
88
|
+
}
|
|
89
|
+
async function assertCopied(parentId, childId, label) {
|
|
90
|
+
// Capture both immediately after fork and before the child's first query.
|
|
91
|
+
const parent = await history(parentId, label + ': parent'), child = await history(childId, label + ': child before first query');
|
|
92
|
+
assert.ok(parent.messages >= 2, 'Parent transcript must include the seed turn');
|
|
93
|
+
assert.deepEqual(child.contentHashes, parent.contentHashes, 'Fork must copy the whole parent history before recall');
|
|
94
|
+
check(label + ': complete parent/child transcript copy before first child query');
|
|
95
|
+
}
|
|
96
|
+
function fixtureText(messages) {
|
|
97
|
+
const users = messages.filter(message => message.role === 'user').map(message =>
|
|
98
|
+
typeof message.content === 'string' ? message.content : (message.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n'));
|
|
99
|
+
const seed = users.join('\n').match(/HCC_FORK_SEED (HCC_BASE_[a-f0-9]+)\|(HCC_BRANCH_[a-f0-9]+)/);
|
|
100
|
+
let branch = seed?.[2];
|
|
101
|
+
for (const text of users) { const changed = /HCC_FORK_SET (HCC_BRANCH_[a-f0-9]+)/.exec(text); if (changed) branch = changed[1]; }
|
|
102
|
+
report.requests.push({ historyHasSeed: Boolean(seed), userMessages: users.length, branchSha256: branch ? hash(branch) : null });
|
|
103
|
+
return seed ? seed[1] + '|' + branch : 'MISSING_CONTEXT';
|
|
104
|
+
}
|
|
105
|
+
function stream(response, model, text) {
|
|
106
|
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
107
|
+
const event = (type, value) => response.write('event: ' + type + '\ndata: ' + JSON.stringify({ type, ...value }) + '\n\n');
|
|
108
|
+
event('message_start', { message: { id: 'msg_' + randomUUID().replaceAll('-', ''), type: 'message', role: 'assistant', model,
|
|
109
|
+
content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
|
|
110
|
+
event('content_block_start', { index: 0, content_block: { type: 'text', text: '' } });
|
|
111
|
+
event('content_block_delta', { index: 0, delta: { type: 'text_delta', text } });
|
|
112
|
+
event('content_block_stop', { index: 0 });
|
|
113
|
+
event('message_delta', { delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
|
114
|
+
event('message_stop', {}); response.end();
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (options.get('--claude-package')) {
|
|
118
|
+
const installed = fs.realpathSync(path.resolve(options.get('--claude-package')));
|
|
119
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8'));
|
|
120
|
+
assert.equal(pkg.name, '@anthropic-ai/claude-agent-sdk');
|
|
121
|
+
const scope = path.join(root, 'node_modules', '@anthropic-ai'); fs.mkdirSync(scope, { recursive: true, mode: 0o700 });
|
|
122
|
+
fs.symlinkSync(installed, path.join(scope, 'claude-agent-sdk'), 'dir');
|
|
123
|
+
}
|
|
124
|
+
const entry = createRequire(path.join(root, 'package.json')).resolve('@anthropic-ai/claude-agent-sdk');
|
|
125
|
+
report.sdk = { entrySha256: digest(entry), version: JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8')).version };
|
|
126
|
+
process.env.HOME = home; process.env.CLAUDE_CONFIG_DIR = providerHome;
|
|
127
|
+
if (live) {
|
|
128
|
+
if (protectedBefore) {
|
|
129
|
+
const source = JSON.parse(fs.readFileSync(settings, 'utf8'));
|
|
130
|
+
const env = Object.fromEntries(Object.entries(source.env || {}).filter(([key, value]) => key.startsWith('ANTHROPIC_') && typeof value === 'string'));
|
|
131
|
+
fs.writeFileSync(path.join(providerHome, 'settings.json'), JSON.stringify({ env,
|
|
132
|
+
...(typeof source.model === 'string' ? { model: source.model } : {}) }), { mode: 0o600 });
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
server = http.createServer(async (request, response) => {
|
|
136
|
+
try {
|
|
137
|
+
assert.equal(request.method, 'POST'); assert.match(request.url, /^\/v1\/messages(?:\?|$)/);
|
|
138
|
+
const chunks = []; let size = 0;
|
|
139
|
+
for await (const chunk of request) { size += chunk.length; assert.ok(size <= 2 * 1024 * 1024); chunks.push(chunk); }
|
|
140
|
+
assert.ok(report.requests.length < 10, 'Fixture request bound exceeded');
|
|
141
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
142
|
+
const text = fixtureText(body.messages);
|
|
143
|
+
if (body.stream) stream(response, body.model, text);
|
|
144
|
+
else { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ id: 'msg_' + randomUUID(), type: 'message', role: 'assistant', model: body.model,
|
|
145
|
+
content: [{ type: 'text', text }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } })); }
|
|
146
|
+
} catch { response.writeHead(500); response.end(JSON.stringify({ error: { type: 'api_error', message: 'Fixture protocol failure' } })); }
|
|
147
|
+
});
|
|
148
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
149
|
+
fs.writeFileSync(path.join(providerHome, 'settings.json'), JSON.stringify({ model: 'claude-sonnet-4-5-20250929', env: {
|
|
150
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:' + server.address().port, ANTHROPIC_API_KEY: 'local-fixture-no-credential',
|
|
151
|
+
ANTHROPIC_AUTH_TOKEN: '', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } }), { mode: 0o600 });
|
|
152
|
+
}
|
|
153
|
+
sdk = await import(pathToFileURL(entry).href);
|
|
154
|
+
assert.equal(typeof sdk.forkSession, 'function'); assert.equal(typeof sdk.getSessionMessages, 'function');
|
|
155
|
+
const base = 'HCC_BASE_' + randomUUID().replaceAll('-', '').slice(0, 16);
|
|
156
|
+
const initial = 'HCC_BRANCH_' + randomUUID().replaceAll('-', '').slice(0, 16);
|
|
157
|
+
const changed = 'HCC_BRANCH_' + randomUUID().replaceAll('-', '').slice(0, 16);
|
|
158
|
+
const recall = 'HCC_FORK_RECALL: Without tools or reading files, reply only with the full base|branch values established earlier, including the original HCC_BASE_ and HCC_BRANCH_ prefixes and their hexadecimal suffixes. Do not infer or invent missing values.';
|
|
159
|
+
const parent = adapter('parent'); await parent.open();
|
|
160
|
+
await turn(parent, 'HCC_FORK_SEED ' + base + '|' + initial + '. Remember both complete values in this conversation. Reply with exactly this complete string including both HCC_ prefixes: ' + base + '|' + initial + '. Do not use tools or read files.', base + '|' + initial);
|
|
161
|
+
const parentId = parent.snapshot().sessionId;
|
|
162
|
+
const child = adapter('child'); await child.open({ forkSessionId: parentId });
|
|
163
|
+
const childId = child.snapshot().sessionId; assert.ok(childId && childId !== parentId);
|
|
164
|
+
await assertCopied(parentId, childId, 'open-parent fork');
|
|
165
|
+
if (!live) assert.equal(report.requests.length, 1, 'Fork must not perform hidden inference');
|
|
166
|
+
await turn(child, recall, base + '|' + initial); check('first child turn recalls both copied values without prompt replay');
|
|
167
|
+
await turn(child, 'HCC_FORK_SET ' + changed + '. Replace only the branch value, keep the original full base value, and reply only with base|branch including the HCC_BASE_ and HCC_BRANCH_ prefixes and hexadecimal suffixes. No tools or file reads.', base + '|' + changed);
|
|
168
|
+
const parentBefore = await history(parentId, 'parent after child-only branch change');
|
|
169
|
+
await turn(parent, recall, base + '|' + initial); check('child branch update leaves parent model-visible values intact');
|
|
170
|
+
const childBeforeResume = await history(childId, 'child before close/resume');
|
|
171
|
+
assert.ok(childBeforeResume.messages > parentBefore.messages);
|
|
172
|
+
await child.close();
|
|
173
|
+
const resumed = adapter('resumed-child'); await resumed.open({ sessionId: childId });
|
|
174
|
+
await turn(resumed, recall, base + '|' + changed);
|
|
175
|
+
assert.equal(resumed.snapshot().sessionId, childId); check('child close/resume retains its own identity and changed branch');
|
|
176
|
+
await parent.close();
|
|
177
|
+
const closedFork = adapter('closed-parent-child'); await closedFork.open({ forkSessionId: parentId });
|
|
178
|
+
const closedForkId = closedFork.snapshot().sessionId;
|
|
179
|
+
assert.ok(closedForkId && closedForkId !== childId && closedForkId !== parentId);
|
|
180
|
+
await assertCopied(parentId, closedForkId, 'closed-parent fork');
|
|
181
|
+
await turn(closedFork, recall, base + '|' + initial); check('closed-parent fork recalls the original branch with a distinct identity');
|
|
182
|
+
report.completed = true;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
report.error = { code: error.code || 'ACCEPTANCE_FAILED', message: redactSecrets(error.message).slice(0, 1500) };
|
|
185
|
+
if (sdk?.getSessionMessages) for (const { name, value } of adapters) {
|
|
186
|
+
const sessionId = value.snapshot().sessionId;
|
|
187
|
+
if (sessionId) try { await history(sessionId, 'failure: ' + name); } catch { /* Preserve earlier snapshots. */ }
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
const closed = await Promise.allSettled(adapters.map(({ value }) => value.close()));
|
|
191
|
+
report.cleanup.adaptersClosed = closed.every(value => value.status === 'fulfilled');
|
|
192
|
+
if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
|
|
193
|
+
for (const [key, value] of Object.entries(originalEnv)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
|
|
194
|
+
report.protectedSettingsUnchanged = protectedBefore === null ? !fs.existsSync(settings) : fs.existsSync(settings) && digest(settings) === protectedBefore;
|
|
195
|
+
report.sourceChanges = Object.entries(sourceFiles).filter(([name, before]) => digest(path.join(repo, name)) !== before).map(([name]) => name);
|
|
196
|
+
try { fs.rmSync(sandbox, { recursive: true, force: true }); report.cleanup.privateProjectAndHomeRemoved = !fs.existsSync(sandbox); }
|
|
197
|
+
catch { report.cleanup.privateProjectAndHomeRemoved = false; }
|
|
198
|
+
report.success = report.completed === true && !report.error && report.cleanup.adaptersClosed && report.cleanup.privateProjectAndHomeRemoved && report.protectedSettingsUnchanged && !report.sourceChanges.length;
|
|
199
|
+
report.finishedAt = new Date().toISOString(); flush();
|
|
200
|
+
}
|
|
201
|
+
console.log(JSON.stringify({ output, success: report.success, realModelCalls: report.realModelCalls, checks: report.checks.length, error: report.error, cleanup: report.cleanup }));
|
|
202
|
+
process.exitCode = report.success ? 0 : 1;
|
package/scripts/regression.mjs
CHANGED
|
@@ -57,6 +57,7 @@ const fixtureFailures = new WeakSet();
|
|
|
57
57
|
const fixtureCommandFailures = new WeakMap();
|
|
58
58
|
const fixtureHttpFailures = new WeakMap();
|
|
59
59
|
const fixtureShimRegistrationFailures = new WeakMap();
|
|
60
|
+
const fixtureTmuxGcFailures = new WeakMap();
|
|
60
61
|
const managedTmuxSessions = new Set();
|
|
61
62
|
|
|
62
63
|
const env = {
|
|
@@ -102,6 +103,9 @@ export function fixtureFailureError(message, commandResult = null, diagnosticOpe
|
|
|
102
103
|
if (diagnosticOperation === 'shim-registration') {
|
|
103
104
|
fixtureShimRegistrationFailures.set(error, fixtureShimRegistrationSummary(commandResult));
|
|
104
105
|
}
|
|
106
|
+
if (diagnosticOperation === 'tmux-gc') {
|
|
107
|
+
fixtureTmuxGcFailures.set(error, fixtureTmuxGcSummary(commandResult));
|
|
108
|
+
}
|
|
105
109
|
return error;
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -117,10 +121,32 @@ export function fixtureFailureDiagnostic(error) {
|
|
|
117
121
|
...(fixtureHttpFailures.has(error) ? { http: fixtureHttpFailures.get(error) } : {}),
|
|
118
122
|
...(fixtureShimRegistrationFailures.has(error)
|
|
119
123
|
? { shimRegistration: fixtureShimRegistrationFailures.get(error) } : {}),
|
|
124
|
+
...(fixtureTmuxGcFailures.has(error) ? { tmuxGc: fixtureTmuxGcFailures.get(error) } : {}),
|
|
120
125
|
...(terminalMarker ? { terminalMarker } : {})
|
|
121
126
|
};
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
export function fixtureTmuxGcSummary(observation = {}) {
|
|
130
|
+
const reasons = ['stale_hcc_managed_session', 'stale_rebind_cleanup_failed_session',
|
|
131
|
+
'tmux_stored_evidence_incomplete', 'tmux_session_changed', 'tmux_session_reused',
|
|
132
|
+
'tmux_session_id_changed', 'tmux_root_unknown', 'tmux_root_changed', 'tmux_pane_changed',
|
|
133
|
+
'tmux_pane_not_dead', 'tmux_dead_process_pid_unknown', 'tmux_dead_process_pid_changed', 'tmux_process_not_confirmed_dead',
|
|
134
|
+
'tmux_process_changed', 'tmux_clients_unknown', 'tmux_has_clients',
|
|
135
|
+
'tmux_binding_subject_incomplete', 'tmux_binding_session_authority_changed',
|
|
136
|
+
'tmux_binding_root_authority_changed', 'tmux_binding_pane_authority_changed',
|
|
137
|
+
'tmux_owner_process_live', 'tmux_owner_process_incompatible', 'tmux_target_missing',
|
|
138
|
+
'not_hcc_managed_name', 'hcc_root_mismatch', 'duplicate_db_binding', 'runtime_managed',
|
|
139
|
+
'not_old_enough', 'old_runtime_target_changed'];
|
|
140
|
+
return Object.freeze(Object.fromEntries(['stale', 'peerFilter', 'deadBinding', 'deadEvent'].map(role => {
|
|
141
|
+
const value = observation?.[role];
|
|
142
|
+
return [role, Object.freeze({
|
|
143
|
+
candidate: typeof value?.candidate === 'boolean' ? value.candidate : null,
|
|
144
|
+
reason: reasons.includes(value?.reason) ? value.reason : 'unknown',
|
|
145
|
+
mode: ['dead_process', 'explicit_exit_live'].includes(value?.mode) ? value.mode : null
|
|
146
|
+
})];
|
|
147
|
+
})));
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
export function fixtureShimRegistrationSummary(observation = {}) {
|
|
125
151
|
// Public annotations retain only fixture-owned booleans and finite enums.
|
|
126
152
|
// CLI tables, provider output, identifiers, paths and environment are excluded.
|
|
@@ -983,6 +1009,17 @@ async function assertTmuxGcPolicy() {
|
|
|
983
1009
|
await waitFor(() => run('tmux', ['display-message', '-p', '-t', deadEventPane, '#{pane_dead}']).trim() === '1', 'dead event pane');
|
|
984
1010
|
await waitFor(() => run('tmux', ['display-message', '-p', '-t', clientUnknownEventPane, '#{pane_dead}']).trim() === '1', 'client-unknown dead event pane');
|
|
985
1011
|
await waitFor(() => run('tmux', ['display-message', '-p', '-t', deadBindingPane, '#{pane_dead}']).trim() === '1', 'dead binding pane');
|
|
1012
|
+
// Recent tmux hides pane_pid after PTY closure. Keep the destructive guard
|
|
1013
|
+
// strict: test deletion only when this host still supplies the original PID,
|
|
1014
|
+
// and require an explicit safe skip otherwise. Never substitute a saved PID.
|
|
1015
|
+
const retainsDeadPanePid = (pane, evidence) => {
|
|
1016
|
+
const pid = Number(run('tmux', ['display-message', '-p', '-t', pane, '#{pane_pid}']).trim());
|
|
1017
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
1018
|
+
if (pid !== evidence.old_process_identity.pid) fail('dead tmux fixture process changed before GC');
|
|
1019
|
+
return true;
|
|
1020
|
+
};
|
|
1021
|
+
const deadBindingGcAvailable = retainsDeadPanePid(deadBindingPane, deadBindingEvidence);
|
|
1022
|
+
const deadEventGcAvailable = retainsDeadPanePid(deadEventPane, deadEventEvidence);
|
|
986
1023
|
|
|
987
1024
|
const attachTmux = realTmuxBin ? `${sh(realTmuxBin)} -L ${sh(tmuxSocketName)}` : 'tmux';
|
|
988
1025
|
const client = spawnSync('tmux', ['new-session', '-d', '-s', `${tmuxSession}-gc-client`, '-e', `HCC_ROOT=${root}`, 'sh', '-lc', `unset TMUX; exec ${attachTmux} attach-session -t ${sh(attachedSession)}`], {
|
|
@@ -1000,21 +1037,35 @@ async function assertTmuxGcPolicy() {
|
|
|
1000
1037
|
HCC_REGRESSION_TMUX_FAIL_CLIENT_SESSIONS: [clientUnknownEventSession, clientUnknownBindingSession].join(' ')
|
|
1001
1038
|
};
|
|
1002
1039
|
const dryRun = hcc(['tmux', 'gc', '--older-than', '14'], { env: gcEnv });
|
|
1040
|
+
const dryRunJson = hccJson(['tmux', 'gc', '--older-than', '14'], { env: gcEnv });
|
|
1041
|
+
const gcObservation = Object.fromEntries(Object.entries({
|
|
1042
|
+
stale: stalePeer, peerFilter: peerFilterPeer, deadBinding: deadBindingPeer, deadEvent: deadEventPeer
|
|
1043
|
+
}).map(([role, peer]) => {
|
|
1044
|
+
const candidate = dryRunJson.candidates.find(row => row.peer === peer);
|
|
1045
|
+
const row = candidate || dryRunJson.skipped.find(row => row.peer === peer);
|
|
1046
|
+
return [role, { candidate: Boolean(candidate), reason: row?.reason, mode: row?.gc_validation_mode }];
|
|
1047
|
+
}));
|
|
1003
1048
|
if (!gcOutputHasPeer(dryRun, stalePeer) || !dryRun.includes('stale_hcc_managed_session') ||
|
|
1004
1049
|
!gcOutputHasPeer(dryRun, peerFilterPeer) ||
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1050
|
+
gcOutputHasPeer(dryRun, deadBindingPeer) !== deadBindingGcAvailable ||
|
|
1051
|
+
gcOutputHasPeer(dryRun, deadEventPeer) !== deadEventGcAvailable ||
|
|
1052
|
+
(deadEventGcAvailable && !dryRun.includes('stale_rebind_cleanup_failed_session'))) {
|
|
1053
|
+
fail(`tmux gc dry-run did not list stale DB-proven managed session:\n${dryRun}\n${JSON.stringify(gcObservation)}`,
|
|
1054
|
+
gcObservation, 'tmux-gc');
|
|
1055
|
+
}
|
|
1056
|
+
for (const [peer, available] of [[deadBindingPeer, deadBindingGcAvailable], [deadEventPeer, deadEventGcAvailable]]) {
|
|
1057
|
+
if (!available && !dryRunJson.skipped.some(row => row.peer === peer && row.reason === 'tmux_dead_process_pid_unknown')) {
|
|
1058
|
+
fail('tmux gc must retain dead panes whose process binding is unavailable', gcObservation, 'tmux-gc');
|
|
1059
|
+
}
|
|
1008
1060
|
}
|
|
1009
1061
|
for (const protectedPeer of [reusedBindingPeer, clientUnknownBindingPeer, eventPeer, reusedEventPeer, reusedProcessEventPeer, clientUnknownEventPeer, rootEventPeer, legacyEventPeer]) {
|
|
1010
1062
|
if (gcOutputHasPeer(dryRun, protectedPeer)) {
|
|
1011
1063
|
fail(`tmux gc dry-run treated live/unknown event evidence as removable (${protectedPeer}):\n${dryRun}`);
|
|
1012
1064
|
}
|
|
1013
1065
|
}
|
|
1014
|
-
const dryRunJson = hccJson(['tmux', 'gc', '--older-than', '14'], { env: gcEnv });
|
|
1015
1066
|
const candidateModes = new Map(dryRunJson.candidates.map((candidate) => [candidate.peer, candidate.gc_validation_mode]));
|
|
1016
1067
|
if (candidateModes.get(stalePeer) !== 'explicit_exit_live' ||
|
|
1017
|
-
candidateModes.get(deadBindingPeer) !== 'dead_process') {
|
|
1068
|
+
candidateModes.get(deadBindingPeer) !== (deadBindingGcAvailable ? 'dead_process' : undefined)) {
|
|
1018
1069
|
fail(`tmux gc did not record strict binding validation modes:\n${JSON.stringify(dryRunJson.candidates, null, 2)}`);
|
|
1019
1070
|
}
|
|
1020
1071
|
if (runMaybe('tmux', ['has-session', '-t', staleSession]).status !== 0) {
|
|
@@ -1037,7 +1088,9 @@ async function assertTmuxGcPolicy() {
|
|
|
1037
1088
|
}
|
|
1038
1089
|
|
|
1039
1090
|
const removed = hcc(['tmux', 'gc', '--older-than', '14', '--yes'], { env: gcEnv });
|
|
1040
|
-
if (!gcOutputHasPeer(removed, stalePeer) ||
|
|
1091
|
+
if (!gcOutputHasPeer(removed, stalePeer) ||
|
|
1092
|
+
gcOutputHasPeer(removed, deadBindingPeer) !== deadBindingGcAvailable ||
|
|
1093
|
+
gcOutputHasPeer(removed, deadEventPeer) !== deadEventGcAvailable ||
|
|
1041
1094
|
gcOutputHasPeer(removed, reusedBindingPeer) || gcOutputHasPeer(removed, clientUnknownBindingPeer) ||
|
|
1042
1095
|
gcOutputHasPeer(removed, eventPeer) || gcOutputHasPeer(removed, reusedEventPeer) ||
|
|
1043
1096
|
gcOutputHasPeer(removed, reusedProcessEventPeer) || gcOutputHasPeer(removed, clientUnknownEventPeer) ||
|
|
@@ -1048,11 +1101,11 @@ async function assertTmuxGcPolicy() {
|
|
|
1048
1101
|
if (runMaybe('tmux', ['has-session', '-t', staleSession]).status === 0) {
|
|
1049
1102
|
fail('tmux gc --yes did not delete stale DB-proven managed session');
|
|
1050
1103
|
}
|
|
1051
|
-
if (runMaybe('tmux', ['has-session', '-t', deadBindingSession]).status
|
|
1052
|
-
fail('tmux gc --yes
|
|
1104
|
+
if ((runMaybe('tmux', ['has-session', '-t', deadBindingSession]).status !== 0) !== deadBindingGcAvailable) {
|
|
1105
|
+
fail('tmux gc --yes must delete only a truly dead exact binding session with its original PID');
|
|
1053
1106
|
}
|
|
1054
|
-
if (runMaybe('tmux', ['has-session', '-t', deadEventSession]).status
|
|
1055
|
-
fail('tmux gc --yes
|
|
1107
|
+
if ((runMaybe('tmux', ['has-session', '-t', deadEventSession]).status !== 0) !== deadEventGcAvailable) {
|
|
1108
|
+
fail('tmux gc --yes must retain rebind cleanup sessions lacking a confirmed process binding');
|
|
1056
1109
|
}
|
|
1057
1110
|
for (const protectedSession of [reusedBindingSession, clientUnknownBindingSession, eventSession, reusedEventSession, reusedProcessEventSession, clientUnknownEventSession, rootEventSession, manualSession]) {
|
|
1058
1111
|
if (runMaybe('tmux', ['has-session', '-t', protectedSession]).status !== 0) {
|
|
@@ -1087,7 +1140,8 @@ async function assertTmuxGcPolicy() {
|
|
|
1087
1140
|
fail(`tmux gc changed attached-client binding:\n${JSON.stringify(attachedBinding, null, 2)}`);
|
|
1088
1141
|
}
|
|
1089
1142
|
const deadBinding = peerBindingRow(deadBindingPeer);
|
|
1090
|
-
if (!deadBinding || deadBinding.transport !== 'detached'
|
|
1143
|
+
if (!deadBinding || deadBinding.transport !== (deadBindingGcAvailable ? 'detached' : 'tmux') ||
|
|
1144
|
+
deadBinding.runtime_target !== (deadBindingGcAvailable ? null : deadBindingPane)) {
|
|
1091
1145
|
fail(`tmux gc did not detach truly dead binding:\n${JSON.stringify(deadBinding, null, 2)}`);
|
|
1092
1146
|
}
|
|
1093
1147
|
for (const [peer, pane] of [
|
|
@@ -5,6 +5,21 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
5
5
|
|
|
6
6
|
const FAILURE_CODE = 'NATIVE_ACCEPTANCE_COMMIT_GAP';
|
|
7
7
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
8
|
+
|
|
9
|
+
// Retain enough to distinguish a conflict from a lost response without
|
|
10
|
+
// writing response text, drafts, authorization headers or action tokens.
|
|
11
|
+
export function summarizeNativeRetryResponse(status, body) {
|
|
12
|
+
const bytes = Buffer.from(body);
|
|
13
|
+
const summary = { status, bytes: bytes.length, bodySha256: hash(bytes), format: 'non-json', code: null };
|
|
14
|
+
if (bytes.length <= 65536) {
|
|
15
|
+
try {
|
|
16
|
+
const value = JSON.parse(bytes.toString('utf8'));
|
|
17
|
+
summary.format = 'json';
|
|
18
|
+
if (typeof value?.error?.code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(value.error.code)) summary.code = value.error.code;
|
|
19
|
+
} catch { /* The hash still identifies an HTML, empty or malformed response. */ }
|
|
20
|
+
} else summary.format = 'oversized';
|
|
21
|
+
return summary;
|
|
22
|
+
}
|
|
8
23
|
function readMesh(dbPath, read) {
|
|
9
24
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
10
25
|
try { return read(db); } finally { db.close(); }
|
|
@@ -62,12 +77,17 @@ export async function runNativeRetryChecks({ browser, base, token, root, dbPath,
|
|
|
62
77
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
63
78
|
await context.addInitScript(browserInstrumentation);
|
|
64
79
|
const page = await context.newPage(); watch(page);
|
|
65
|
-
const requests = [], responses = [];
|
|
80
|
+
const requests = [], responses = [], responseDetails = [], responseReads = [];
|
|
66
81
|
page.on('request', request => {
|
|
67
82
|
if (request.method() === 'POST' && new URL(request.url()).pathname === route) requests.push(request.postDataJSON());
|
|
68
83
|
});
|
|
69
84
|
page.on('response', response => {
|
|
70
|
-
if (response.request().method()
|
|
85
|
+
if (response.request().method() !== 'POST' || new URL(response.url()).pathname !== route) return;
|
|
86
|
+
responses.push(response.status());
|
|
87
|
+
const summary = { status: response.status(), format: 'unavailable', code: null };
|
|
88
|
+
responseDetails.push(summary);
|
|
89
|
+
responseReads.push(response.body().then(body => Object.assign(summary, summarizeNativeRetryResponse(response.status(), body)))
|
|
90
|
+
.catch(() => { summary.format = 'unavailable'; }));
|
|
71
91
|
});
|
|
72
92
|
let observerContext, workerStarted = false, failure;
|
|
73
93
|
try {
|
|
@@ -147,17 +167,21 @@ export async function runNativeRetryChecks({ browser, base, token, root, dbPath,
|
|
|
147
167
|
await shot(page, viewport.name + '-native-retry-reconciled');
|
|
148
168
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
|
|
149
169
|
assert.deepEqual(responses, [409, 200]);
|
|
170
|
+
await Promise.all(responseReads);
|
|
171
|
+
assert.equal(responseDetails[0].code, FAILURE_CODE);
|
|
172
|
+
assert.equal(responseDetails[1].format, 'json');
|
|
150
173
|
receipt.scenarios.push({ viewport, peer, submissionId, messageId: message.message_id,
|
|
151
174
|
owner: originalState.owner, generation: originalState.generation,
|
|
152
|
-
requestCount: requests.length, responseStatuses: responses, expectedFailure: FAILURE_CODE,
|
|
175
|
+
requestCount: requests.length, responseStatuses: responses, responseDetails, expectedFailure: FAILURE_CODE,
|
|
153
176
|
originalBodySha256: hash(original), laterDraftPreserved: true, observerRetryBlocked: true,
|
|
154
177
|
messages: counts.messages, durableIntents: counts.intents, pendingEvents: counts.pendingEvents,
|
|
155
178
|
deliveries: active.deliveries.length, userOriginRetained: true, providerInputs: adapter.sent });
|
|
156
179
|
check('native retry ' + viewport.name + ': original durable submission repairs once; edited draft and observer fence survive');
|
|
157
180
|
} catch (error) {
|
|
158
181
|
failure = error;
|
|
182
|
+
await Promise.all(responseReads);
|
|
159
183
|
receipt.failure = { viewport: viewport.name, peer, message: error.message, requestCount: requests.length,
|
|
160
|
-
responseStatuses: responses, injected: retryFault.injected.filter(value => value.peer === peer) };
|
|
184
|
+
responseStatuses: responses, responseDetails, injected: retryFault.injected.filter(value => value.peer === peer) };
|
|
161
185
|
try { await shot(page, viewport.name + '-native-retry-failure'); } catch { /* Preserve the original assertion. */ }
|
|
162
186
|
throw error;
|
|
163
187
|
} finally {
|