@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -0,0 +1,368 @@
1
+ // Opt-in installed Codex + real model + browser form acceptance.
2
+ // The MCP test server sends real elicitation/create requests; no App Server
3
+ // requests, model outputs or adapter responses are injected.
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import net from 'node:net';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { spawnSync, execFile } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { DatabaseSync } from 'node:sqlite';
13
+ import { setTimeout as delay } from 'node:timers/promises';
14
+ import { initSchema } from '../lib/db/schema.mjs';
15
+ import { createEventHelpers } from '../lib/db/events.mjs';
16
+ import { createPeerHelpers } from '../lib/core/peers/peer-helpers.mjs';
17
+ import { createPeerBindingStore } from '../lib/db/stores/peers.mjs';
18
+ import { createMessageStore } from '../lib/core/coordination/messages.mjs';
19
+ import { inspectProcessIdentity } from '../lib/process/identity.mjs';
20
+ import { createCodexAdapter } from '../lib/integrations/native/codex.mjs';
21
+ import { JsonRpcProcess } from '../lib/integrations/native/jsonrpc.mjs';
22
+ import { startNativeService } from '../lib/runtime/native/service.mjs';
23
+ import { nativeRequest } from '../lib/runtime/native/client.mjs';
24
+
25
+ const args = process.argv.slice(2);
26
+ const option = (name, fallback) => args.includes(name) ? args[args.indexOf(name) + 1] : fallback;
27
+ if (!args.includes('--run-live')) {
28
+ console.log('Usage: node scripts/web-mcp-form-live-acceptance.mjs --run-live [--codex-bin PATH] [--output FILE]');
29
+ console.log('Set HCC_ACCEPTANCE_PLAYWRIGHT and optionally HCC_ACCEPTANCE_CHROME / HCC_ACCEPTANCE_TMUX.');
30
+ console.log('Uses the existing Codex model/account in a disposable home. Real inference consumes provider quota.');
31
+ console.log('Tests local pending form -> Web typed response -> same native task -> local return, and Web-owned App Server forms.');
32
+ process.exit(0);
33
+ }
34
+ const repo = fileURLToPath(new URL('..', import.meta.url));
35
+ const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
36
+ function sourceHashes() {
37
+ const result = {};
38
+ function walk(folder) {
39
+ for (const entry of fs.readdirSync(path.join(repo, folder), { withFileTypes: true })) {
40
+ const file = path.join(folder, entry.name);
41
+ if (entry.isDirectory()) walk(file);
42
+ else if (entry.isFile()) result[file] = digest(path.join(repo, file));
43
+ }
44
+ }
45
+ for (const folder of ['bin', 'lib', 'scripts', 'test']) walk(folder);
46
+ for (const file of ['package.json', 'package-lock.json']) result[file] = digest(path.join(repo, file));
47
+ return result;
48
+ }
49
+ const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-live-mcp-form-')));
50
+ fs.chmodSync(directory, 0o700);
51
+ const root = path.join(directory, 'project'), home = path.join(directory, 'codex-home'), bin = path.join(directory, 'bin'), userHome = path.join(directory, 'home');
52
+ for (const folder of [root, home, bin, userHome, path.join(root, '.hello-cc')]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
53
+ const output = path.resolve(option('--output', path.join(directory, 'evidence.json')));
54
+ assert.ok(!fs.existsSync(output), 'Use a new output path; existing receipts are preserved');
55
+ const mcpLog = path.join(directory, 'synthetic-mcp-rpc.jsonl');
56
+ fs.writeFileSync(mcpLog, '', { mode: 0o600 });
57
+ const originalHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
58
+ const gitIndexResult = spawnSync('git', ['rev-parse', '--git-path', 'index'], { cwd: repo, encoding: 'utf8' });
59
+ const gitIndex = gitIndexResult.status === 0 ? path.resolve(repo, gitIndexResult.stdout.trim()) : null;
60
+ const originalUserHome = process.env.HOME || os.homedir();
61
+ const protectedFiles = [path.join(originalHome, 'config.toml'), path.join(originalHome, 'auth.json'), gitIndex,
62
+ ...['.zshrc', '.bashrc', '.bash_profile', '.profile'].map(name => path.join(originalUserHome, name))].filter(Boolean);
63
+ const protectedHashes = protectedFiles.map(file => [file, fs.existsSync(file) ? digest(file) : null]);
64
+ const evidence = { startedAt: new Date().toISOString(), node: process.version, directory, sourceRoot: repo,
65
+ sourceFiles: sourceHashes(), modelInferenceCalled: false, requestInjection: false,
66
+ providerMode: 'installed Codex; real model calls a disposable stdio MCP server; production HCC runtime and browser',
67
+ browser: 'Browser plugin not available; isolated installed Chrome and Playwright', checks: [], screenshots: [],
68
+ pageErrors: [], consoleErrors: [], completed: false };
69
+ const codexBin = option('--codex-bin', '/opt/homebrew/bin/codex');
70
+ const tmux = process.env.HCC_ACCEPTANCE_TMUX || '/opt/homebrew/bin/tmux';
71
+ const socket = 'hcc-live-mcp-' + randomUUID();
72
+ const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
73
+ const env = { ...process.env, PATH: bin + ':' + path.dirname(process.execPath) + ':' + process.env.PATH,
74
+ HOME: userHome, CODEX_HOME: home, HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1', HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID() };
75
+ for (const key of Object.keys(env)) if (key.startsWith('HCC_') && !['HCC_WEB_TOKEN', 'HCC_NO_AUTO_INSTALL_TMUX', 'HCC_SHIM_ENSURED', 'HCC_SHIM_NO_ATTACH'].includes(key)) delete env[key];
76
+ const ctx = { root, dbPath: path.join(root, '.hello-cc', 'mesh.db') };
77
+ const connect = () => { const db = new DatabaseSync(ctx.dbPath); db.exec('PRAGMA busy_timeout=5000'); initSchema(db); return db; };
78
+ const events = createEventHelpers(), bindings = createPeerBindingStore(events);
79
+ const peers = createPeerHelpers({ ...events, now: () => Math.floor(Date.now() / 1000) });
80
+ const deps = { ...events, ...bindings, ...peers, ...createMessageStore(events), connect, detectBranch: () => '',
81
+ liveProcessIdentity: pid => inspectProcessIdentity(pid).identity };
82
+ const api = (method, route, body) => nativeRequest(ctx, method, route, body, { timeoutMs: 30000 });
83
+ const records = () => fs.readFileSync(mcpLog, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
84
+ async function hcc(...params) {
85
+ return new Promise((resolve, reject) => {
86
+ execFile(process.execPath, [path.join(repo, 'bin/hcc.mjs'), '--root', root, ...params],
87
+ { cwd: root, env, encoding: 'utf8', timeout: 30000 }, (error, stdout, stderr) => {
88
+ if (!error) { resolve(stdout); return; }
89
+ evidence.cliFailures ||= [];
90
+ evidence.cliFailures.push({ command: params[0], code: error.code, signal: error.signal,
91
+ stderr: String(stderr || '').replaceAll(env.HCC_WEB_TOKEN, '[isolated-token]').slice(-3000) });
92
+ reject(new Error('Isolated HCC CLI failed: ' + params[0]));
93
+ });
94
+ });
95
+ }
96
+
97
+ function check(name, details = {}) { evidence.checks.push({ name, passed: true, ...details }); console.log('PASS ' + name); }
98
+ async function until(predicate, label, timeout = 180000) {
99
+ const deadline = Date.now() + timeout; let notice = Date.now() + 20000;
100
+ while (Date.now() < deadline) {
101
+ const result = await predicate(); if (result) return result;
102
+ if (Date.now() >= notice) { console.log('WAIT ' + label); notice += 20000; }
103
+ await delay(200);
104
+ }
105
+ throw new Error('Timed out: ' + label);
106
+ }
107
+ function failedNativeState(state) {
108
+ evidence.nativeFailure = { status: state.snapshot?.status, turnId: state.snapshot?.turnId,
109
+ deliveries: state.deliveries, errors: state.events.filter(entry => entry.payload?.type === 'error').map(entry => entry.payload),
110
+ messages: state.events.filter(entry => ['message', 'completed'].includes(entry.payload?.type)).map(entry => ({ type: entry.payload.type, status: entry.payload.status, text: entry.payload.text })) };
111
+ }
112
+ async function completed(submissionId) {
113
+ return until(async () => {
114
+ const state = await api('GET', '/workers/live-mcp/state');
115
+ const delivery = state.deliveries.find(row => row.submission_id === submissionId);
116
+ if (['failed', 'uncertain', 'interrupted'].includes(delivery?.state)) { failedNativeState(state); throw new Error('Native delivery ended in ' + delivery.state); }
117
+ const unexpected = state.snapshot.pendingApprovals?.find(request => request.kind !== 'mcp');
118
+ if (unexpected) throw new Error('Unexpected interaction: ' + unexpected.method);
119
+ return delivery?.state === 'completed' ? state : null;
120
+ }, 'native model completes accepted form');
121
+ }
122
+ const expected = {
123
+ native: { project: 'native-mcp-验收', count: 2, enabled: false, region: 'us', features: ['logs', 'tests'] },
124
+ 'web-owned': { project: 'web-owned-mcp-验收', count: 3, enabled: false, region: 'eu', features: ['tests'] }
125
+ };
126
+ const prompt = phase => 'Call the hcc_form_acceptance MCP tool collect_project_settings with phase "' + phase + '" exactly once. '
127
+ + 'Wait for the human form response. If accepted, use your project file tool to write ' + phase + '-answer.json in the current project directory. '
128
+ + 'The file must contain exactly the JSON content object returned by the tool, without action, phase or extra keys. '
129
+ + 'Do not guess the human values. You may use apply_patch or a local shell command only to write this one project JSON file; do not perform other operations. Finish with FORM_' + (phase === 'native' ? 'NATIVE' : 'WEB') + '_COMPLETE.';
130
+ function response(phase) {
131
+ const rows = records().filter(row => row.event === 'form-response' && row.phase === phase);
132
+ assert.equal(rows.length, 1, 'one real MCP response for ' + phase);
133
+ assert.deepEqual(rows[0].response, { action: 'accept', content: expected[phase] });
134
+ const result = JSON.parse(fs.readFileSync(path.join(root, phase + '-answer.json'), 'utf8'));
135
+ assert.deepEqual(result, expected[phase]);
136
+ assert.ok(fs.statSync(path.join(root, phase + '-answer.json')).mtimeMs >= Date.parse(rows[0].at), 'model writes after accepted form');
137
+ return { elicitationId: rows[0].elicitationId, content: result };
138
+ }
139
+ const fieldTitles = { project: 'Project name / 项目名', count: 'Run count / 次数',
140
+ enabled: 'Enable extra operation / 开启额外操作', region: 'Region / 地区', features: 'Features / 功能', note: 'Optional note / 可选备注' };
141
+ const field = (page, selector, name) => page.locator(selector + ' .hcc-mcp-field').filter({ has: page.locator('legend').filter({ hasText: fieldTitles[name] }) });
142
+ async function fill(page, selector, phase) {
143
+ const values = expected[phase];
144
+ await field(page, selector, 'project').locator('[data-mcp-field]').fill(values.project);
145
+ await field(page, selector, 'count').locator('[data-mcp-field]').fill(String(values.count));
146
+ await field(page, selector, 'enabled').locator('[data-mcp-field]').selectOption('1');
147
+ await field(page, selector, 'region').locator('[data-mcp-field]').selectOption(values.region === 'eu' ? '0' : '1');
148
+ for (const [index, value] of ['logs', 'tests'].entries()) await field(page, selector, 'features').locator('[data-mcp-option="' + index + '"]').setChecked(values.features.includes(value));
149
+ assert.equal(await field(page, selector, 'note').locator('[data-mcp-use]').isChecked(), false);
150
+ }
151
+ async function awaitForm(page, selector, phase) {
152
+ let confirmations = 0;
153
+ await until(async () => {
154
+ if (await field(page, selector, 'project').count()) return true;
155
+ const button = page.locator(selector + ' button[data-decision="accept"]');
156
+ if (await button.count() && await button.isEnabled()) {
157
+ assert.equal(await page.locator(selector + ' [data-mcp-field]').count(), 0, 'only an empty MCP confirmation may precede the form');
158
+ assert.ok((await page.locator(selector).innerText()).includes('hcc_form_acceptance'), 'confirmation belongs to the isolated MCP server');
159
+ assert.equal(confirmations++, 0, 'at most one tool confirmation per phase');
160
+ await button.click();
161
+ check(phase + ' Web explicitly accepts the real MCP tool confirmation before the field form');
162
+ }
163
+ if (records().some(row => row.event === 'form-timeout')) throw new Error('Real MCP server timed out');
164
+ return false;
165
+ }, phase + ' installed model calls the real MCP field form');
166
+ assert.ok(records().find(row => row.event === 'form-requested' && row.phase === phase));
167
+ }
168
+
169
+ async function shot(page, name) {
170
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
171
+ const file = path.join(directory, name + '.png');
172
+ await page.screenshot({ path: file, animations: 'disabled' }); evidence.screenshots.push(file);
173
+ }
174
+ async function rendered(page, type) {
175
+ await page.waitForFunction(type => window.hccHandoff?.session?.type === type && window.hccHandoff.canControl, type, { timeout: 20000 });
176
+ assert.ok((await page.title()).includes('hello-cc'));
177
+ const panel = type === 'native' ? '#nativePanel' : '#codexPanel';
178
+ assert.equal(await page.locator(panel).isVisible(), true);
179
+ assert.ok((await page.locator(panel).innerText()).trim().length > 50);
180
+ assert.equal(await page.locator('vite-error-overlay, nextjs-portal, #webpack-dev-server-client-overlay').count(), 0);
181
+ }
182
+ let service, browser, runtimeStarted = false, nativeChild, page, base;
183
+ try {
184
+ assert.ok(fs.existsSync(tmux), 'tmux must be installed');
185
+ assert.ok(fs.existsSync(codexBin), 'Codex must be installed');
186
+ assert.ok(process.env.HCC_ACCEPTANCE_PLAYWRIGHT, 'Set HCC_ACCEPTANCE_PLAYWRIGHT');
187
+ const source = fs.existsSync(path.join(originalHome, 'config.toml')) ? fs.readFileSync(path.join(originalHome, 'config.toml'), 'utf8') : '';
188
+ let section = '';
189
+ let config = source.split('\n').filter(line => {
190
+ const header = line.match(/^\s*\[\[?([^\]]+)\]\]?/); if (header) section = header[1].split('.')[0].replaceAll('"', '').replaceAll("'", '');
191
+ return section ? section === 'model_providers' : /^\s*(?:model(?:_provider|_reasoning_effort|_reasoning_summary|_verbosity|_catalog_json)?|cli_auth_credentials_store)\s*=/.test(line);
192
+ }).join('\n');
193
+ evidence.model = JSON.parse(source.match(/^model\s*=\s*(".*")\s*$/m)?.[1] || 'null');
194
+ // Managed providers may use a read-only external auth helper whose bootstrap
195
+ // resolves the user's installed runtime via HOME. Keep only that helper on
196
+ // its original home; HCC, Codex and their writes remain in disposable homes.
197
+ const selectedProvider = JSON.parse(source.match(/^model_provider\s*=\s*(".*")\s*$/m)?.[1] || '"openai"');
198
+ const authHeadings = ['model_providers.' + selectedProvider + '.auth', 'model_providers."' + selectedProvider + '".auth'];
199
+ let authHelperCount = 0;
200
+ config = config.split(/(?=^\[)/m).map(block => {
201
+ const heading = /^\[([^\]]+)\]/.exec(block)?.[1];
202
+ if (!authHeadings.includes(heading)) return block;
203
+ const command = JSON.parse(block.match(/^\s*command\s*=\s*(.+)$/m)?.[1] || 'null');
204
+ const helperArgs = JSON.parse(block.match(/^\s*args\s*=\s*(.+)$/m)?.[1] || '[]');
205
+ assert.ok(typeof command === 'string' && Array.isArray(helperArgs) && helperArgs.every(arg => typeof arg === 'string'), 'Cannot isolate the configured auth helper');
206
+ const wrappedArgs = ['HOME=' + originalUserHome, command, ...helperArgs];
207
+ let result = block.replace(/^\s*command\s*=.*$/m, 'command = "/usr/bin/env"');
208
+ if (/^\s*args\s*=/m.test(result)) result = result.replace(/^\s*args\s*=.*$/m, 'args = ' + JSON.stringify(wrappedArgs));
209
+ else result += '\nargs = ' + JSON.stringify(wrappedArgs) + '\n';
210
+ authHelperCount++; return result;
211
+ }).join('');
212
+ evidence.authHelperIsolation = { configured: authHelperCount === 1, originalHomeScope: 'selected credential helper only; Codex and HCC homes are disposable' };
213
+
214
+ const serverConfig = '\n[mcp_servers.hcc_form_acceptance]\ncommand = ' + JSON.stringify(process.execPath)
215
+ + '\nargs = ' + JSON.stringify([path.join(repo, 'scripts/mcp-form-live-fixture.mjs'), '--stdio', '--log', mcpLog])
216
+ + '\nstartup_timeout_sec = 30\ntool_timeout_sec = 240\n';
217
+ fs.writeFileSync(path.join(home, 'config.toml'), config + '\n' + serverConfig, { mode: 0o600 });
218
+ if (fs.existsSync(path.join(originalHome, 'auth.json'))) {
219
+ fs.copyFileSync(path.join(originalHome, 'auth.json'), path.join(home, 'auth.json')); fs.chmodSync(path.join(home, 'auth.json'), 0o600);
220
+ }
221
+ fs.writeFileSync(path.join(bin, 'tmux'), '#!/bin/sh\nexec ' + quote(tmux) + ' -L ' + quote(socket) + ' "$@"\n', { mode: 0o700 });
222
+ fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\nexec ' + quote(codexBin) + ' "$@"\n', { mode: 0o700 });
223
+ const version = spawnSync(codexBin, ['--version'], { env, encoding: 'utf8', timeout: 5000 });
224
+ assert.equal(version.status, 0); evidence.codexVersion = version.stdout.trim();
225
+ await hcc('up', '--no-discover', '--no-guidance');
226
+ service = await startNativeService(ctx, deps, { adapterFactory: (provider, options) => {
227
+ assert.equal(provider, 'codex');
228
+ return createCodexAdapter({ ...options, binary: codexBin,
229
+ env: { ...options.env, ...env, HCC_PEER: options.env.HCC_PEER, HCC_ROOT: root, HCC_DB: ctx.dbPath, HCC_NATIVE_OWNER: options.executorId },
230
+ rpcFactory: config => { const rpc = new JsonRpcProcess(config), start = rpc.start.bind(rpc);
231
+ rpc.start = async () => { await start(); nativeChild = rpc.child; }; return rpc; } });
232
+ } });
233
+ const worker = await api('POST', '/workers', { peer: 'live-mcp', provider: 'codex' });
234
+ const originalIdentity = { sessionId: worker.sessionId, executorId: worker.executorId, pid: nativeChild.pid };
235
+ evidence.modelInferenceCalled = true;
236
+ const local = await api('POST', '/send', { peer: 'live-mcp', from: 'shell', submissionId: 'mcp_local_' + randomUUID(), body: prompt('native') });
237
+ const pending = await until(async () => {
238
+ const state = await api('GET', '/workers/live-mcp/state');
239
+ const request = state.snapshot.pendingApprovals?.find(request => request.kind === 'mcp');
240
+ const delivery = state.deliveries.find(row => row.submission_id === local.submission_id);
241
+ if (['failed', 'uncertain', 'completed'].includes(delivery?.state)) { failedNativeState(state); throw new Error('Native turn ended before real MCP form: ' + delivery.state); }
242
+ if (state.snapshot.pendingApprovals?.some(request => request.kind !== 'mcp')) throw new Error('Unexpected native interaction before MCP form');
243
+ return request ? { request, state } : null;
244
+ }, 'installed model calls MCP form before Web starts');
245
+ assert.equal(records().some(row => row.event === 'form-response'), false);
246
+ evidence.nativeInitialRequest = { method: pending.request.method, mode: pending.request.params.mode, serverName: pending.request.params.serverName, fieldCount: Object.keys(pending.request.params.requestedSchema?.properties || {}).length };
247
+ assert.equal(pending.request.sessionId, originalIdentity.sessionId);
248
+ assert.equal(pending.request.params.mode, 'form');
249
+ check('real native model reaches MCP interaction and remains pending before Web starts', { ...originalIdentity, turnId: pending.request.turnId, requestId: pending.request.requestId });
250
+ const port = await new Promise(resolve => { const listener = net.createServer(); listener.listen(0, '127.0.0.1', () => { const port = listener.address().port; listener.close(() => resolve(port)); }); });
251
+ base = 'http://127.0.0.1:' + port; evidence.baseUrl = base;
252
+ await hcc('web', '--local', '--port', String(port), '--no-discover', '--no-guidance'); runtimeStarted = true;
253
+ const { chromium } = await import(process.env.HCC_ACCEPTANCE_PLAYWRIGHT);
254
+ browser = await chromium.launch({ headless: true, ...(process.env.HCC_ACCEPTANCE_CHROME ? { executablePath: process.env.HCC_ACCEPTANCE_CHROME } : {}) });
255
+ page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
256
+ page.on('pageerror', error => evidence.pageErrors.push(error.message));
257
+ page.on('console', message => { if (['warning', 'error'].includes(message.type())) evidence.consoleErrors.push(message.text()); });
258
+ await page.goto(base + '/?token=' + env.HCC_WEB_TOKEN); await rendered(page, 'native');
259
+ await awaitForm(page, '#nativeApprovals', 'native');
260
+ check('Web opens the original native task and renders the real MCP form');
261
+ await fill(page, '#nativeApprovals', 'native');
262
+ await field(page, '#nativeApprovals', 'count').locator('[data-mcp-field]').fill('9');
263
+ await page.locator('#nativeApprovals button[data-decision="accept"]').click();
264
+ await page.waitForFunction(() => document.getElementById('nativeNotice').textContent.includes('count'));
265
+ assert.equal(records().some(row => row.event === 'form-response'), false);
266
+ assert.equal((await api('GET', '/workers/live-mcp/state')).snapshot.pendingApprovals.length, 1);
267
+ check('invalid live form answer is blocked and the actual model request stays pending');
268
+ await field(page, '#nativeApprovals', 'count').locator('[data-mcp-field]').fill('2');
269
+ await page.locator('#nativeRead').click();
270
+ await until(async () => await field(page, '#nativeApprovals', 'project').locator('[data-mcp-field]').inputValue() === expected.native.project, 'native draft retained');
271
+ await page.evaluate(() => { document.querySelector('.native-scroll').scrollTop = 0; });
272
+ await shot(page, 'native-real-mcp-form-desktop');
273
+ await page.setViewportSize({ width: 390, height: 844 });
274
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
275
+ await shot(page, 'native-real-mcp-form-mobile');
276
+ await page.locator('#nativeApprovals button[data-decision="accept"]').click();
277
+ const afterNative = await completed(local.submission_id);
278
+ evidence.nativeCompletedMessages = afterNative.events.filter(entry => ['message', 'completed'].includes(entry.payload?.type)).map(entry => ({ type: entry.payload.type, text: entry.payload.text, status: entry.payload.status }));
279
+ assert.equal(afterNative.snapshot.sessionId, originalIdentity.sessionId);
280
+ assert.equal(afterNative.snapshot.executorId, originalIdentity.executorId); assert.equal(nativeChild.pid, originalIdentity.pid);
281
+ assert.equal(nativeChild.exitCode, null);
282
+ assert.ok(afterNative.events.some(entry => entry.payload?.type === 'completed' && entry.payload.submissionId === local.submission_id && entry.payload.text?.includes('FORM_NATIVE_COMPLETE')));
283
+ check('mobile Web submits typed values to the live native model and the same task completes', { ...originalIdentity, ...response('native') });
284
+ await page.locator('#nativeRead').click();
285
+ await page.waitForFunction(status => document.getElementById('nativeStatus').textContent.endsWith(' · ' + window.hccHandoff.tr('native.state.' + status, status))
286
+ && document.getElementById('nativeEvents').textContent.includes('FORM_NATIVE_COMPLETE')
287
+ && document.querySelectorAll('#nativeApprovals [data-mcp-field]').length === 0, afterNative.snapshot.status, { timeout: 20000 });
288
+ assert.equal(await page.locator('#nativeNotice').innerText(), '', 'successful retry removes the earlier invalid-value notice');
289
+ await page.evaluate(() => { const scroll = document.querySelector('.native-scroll'); scroll.scrollTop = scroll.scrollHeight; });
290
+ evidence.nativeCompletedUi = { status: await page.locator('#nativeStatus').innerText(),
291
+ markerVisible: (await page.locator('#nativeEvents').innerText()).includes('FORM_NATIVE_COMPLETE'), noticeCleared: true };
292
+ await shot(page, 'native-real-mcp-completed-mobile');
293
+ await page.setViewportSize({ width: 1440, height: 1000 });
294
+ const web = await page.evaluate(async root => window.hccHandoff.api('/api/sessions?root=' + encodeURIComponent(root), {
295
+ method: 'POST', body: JSON.stringify({ kind: 'codex', transport: 'app-server' })
296
+ }), root);
297
+ const webId = web.session.id;
298
+ await page.evaluate(async id => { await window.hccHandoff.refreshSessions(); window.hccHandoff.openManaged(id); }, webId);
299
+ await rendered(page, 'app-server');
300
+ await page.locator('#codexDraft').fill(prompt('web-owned')); await page.locator('#codexSend').click();
301
+ await awaitForm(page, '#codexApprovals', 'web-owned');
302
+ const webState = await page.evaluate(async ({ id, root }) => (await window.hccHandoff.api('/api/sessions/' + encodeURIComponent(id) + '/codex/state?root=' + encodeURIComponent(root))).state, { id: webId, root });
303
+ const webRequest = webState.pendingApprovals.find(request => request.method === 'mcpServer/elicitation/request' && Object.keys(request.params.requestedSchema?.properties || {}).length);
304
+ assert.ok(webRequest);
305
+ const webIdentity = { managedSessionId: webId, threadId: webRequest.threadId, turnId: webRequest.turnId,
306
+ executorId: webState.executorId, pid: webState.pid, requestId: webRequest.requestId };
307
+ await fill(page, '#codexApprovals', 'web-owned'); await shot(page, 'web-owned-real-mcp-form-desktop');
308
+ await page.locator('#codexApprovals button[data-decision="accept"]').click();
309
+ const afterWeb = await until(async () => {
310
+ const state = await page.evaluate(async ({ id, root }) => (await window.hccHandoff.api('/api/sessions/' + encodeURIComponent(id) + '/codex/state?root=' + encodeURIComponent(root))).state, { id: webId, root });
311
+ const turn = state.threads.find(thread => thread.id === webIdentity.threadId)?.turns.find(turn => turn.id === webIdentity.turnId);
312
+ if (['failed', 'interrupted'].includes(turn?.status)) throw new Error('Web-owned turn ended in ' + turn.status);
313
+ return turn?.status === 'completed' ? state : null;
314
+ }, 'Web-owned provider turn actually completes');
315
+ const completedTurn = afterWeb.threads.find(thread => thread.id === webIdentity.threadId).turns.find(turn => turn.id === webIdentity.turnId);
316
+ assert.ok(completedTurn.items.some(item => item.type === 'agentMessage' && item.text?.includes('FORM_WEB_COMPLETE')), 'completion marker belongs to an assistant message');
317
+ assert.equal(afterWeb.executorId, webIdentity.executorId); assert.equal(afterWeb.pid, webIdentity.pid);
318
+ assert.equal(afterWeb.threadId, webIdentity.threadId); assert.equal(afterWeb.turnId, null);
319
+ assert.equal(afterWeb.pendingApprovals.length, 0);
320
+ evidence.webCompletedTurn = { ...webIdentity, status: completedTurn.status,
321
+ assistantMessages: completedTurn.items.filter(item => item.type === 'agentMessage').map(item => item.text) };
322
+ check('Web-owned App Server returns the real typed answer, model writes the result and provider confirms turn completion', { ...webIdentity, ...response('web-owned') });
323
+ await page.locator('#codexRecover').click();
324
+ if (await page.locator('#codexJump').isVisible()) await page.locator('#codexJump').click();
325
+ await shot(page, 'web-owned-real-mcp-completed-desktop');
326
+ await page.evaluate(id => window.hccHandoff.openManaged(id), 'live-mcp'); await rendered(page, 'native');
327
+ await page.locator('#releaseControlBtn').click(); await page.waitForFunction(() => !window.hccHandoff.canControl);
328
+ await browser.close(); browser = null;
329
+ await hcc('down'); runtimeStarted = false;
330
+ const back = await api('POST', '/send', { peer: 'live-mcp', from: 'shell', submissionId: 'mcp_return_' + randomUUID(), body:
331
+ 'Continue this same task after Web released control. Read native-answer.json and use your project file tool to write local-return.json with exactly {"countPlusOne": count + 1} using the saved count. You may use apply_patch or a local shell command only to read native-answer.json and write local-return.json in this project. Do not perform other operations. End with FORM_LOCAL_RETURN_COMPLETE.' });
332
+ const afterReturn = await completed(back.submission_id);
333
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'local-return.json'), 'utf8')), { countPlusOne: expected.native.count + 1 });
334
+ assert.equal(afterReturn.snapshot.sessionId, originalIdentity.sessionId);
335
+ assert.equal(afterReturn.snapshot.executorId, originalIdentity.executorId); assert.equal(nativeChild.pid, originalIdentity.pid);
336
+ assert.equal(nativeChild.exitCode, null);
337
+ assert.ok(afterReturn.events.some(entry => entry.payload?.type === 'completed' && entry.payload.submissionId === back.submission_id && entry.payload.text?.includes('FORM_LOCAL_RETURN_COMPLETE')));
338
+ check('local return after Web shutdown continues the same native executor and uses its accepted answer', originalIdentity);
339
+ const initialized = records().filter(row => row.event === 'initialize');
340
+ assert.ok(initialized.length >= 2); evidence.mcpClients = initialized;
341
+ evidence.mcpResponses = records().filter(row => row.event === 'form-response');
342
+ assert.deepEqual(evidence.pageErrors, []); assert.deepEqual(evidence.consoleErrors, []);
343
+ check('desktop/mobile page identity, nonblank panels, no framework overlay and no browser warnings/errors');
344
+ evidence.completed = true;
345
+ } catch (error) {
346
+ evidence.error = error.message; evidence.errorStack = error.stack; evidence.completed = false; process.exitCode = 1;
347
+ console.error('LIVE_MCP_FORM_FAILED: ' + error.message);
348
+ if (page && !page.isClosed()) try { await shot(page, 'failure'); } catch {}
349
+ } finally {
350
+ try { await browser?.close(); } catch (error) { evidence.browserCleanupError = error.message; }
351
+ if (runtimeStarted) try { await hcc('down'); } catch (error) { evidence.runtimeCleanupError = error.message; }
352
+ try { await service?.shutdown(); } catch (error) { evidence.serviceCleanupError = error.message; }
353
+ spawnSync(tmux, ['-L', socket, 'kill-server'], { stdio: 'ignore', timeout: 5000 });
354
+ evidence.protectedStateUnchanged = protectedHashes.every(([file, original]) => (fs.existsSync(file) ? digest(file) : null) === original);
355
+ const after = sourceHashes();
356
+ evidence.sourceChangesDuringValidation = [...new Set([...Object.keys(evidence.sourceFiles), ...Object.keys(after)])].filter(file => after[file] !== evidence.sourceFiles[file]);
357
+ evidence.fixturePids = [...new Set(records().map(row => row.pid))];
358
+ evidence.fixtureProcessesStopped = await until(() => evidence.fixturePids.every(pid => inspectProcessIdentity(pid).state === 'dead'), 'private MCP processes stop', 10000).catch(() => false);
359
+ evidence.nativeProcessStopped = !nativeChild || nativeChild.exitCode !== null || nativeChild.signalCode !== null;
360
+ for (const folder of [root, home, bin, userHome]) fs.rmSync(folder, { recursive: true, force: true });
361
+ evidence.privateHomesRemoved = !fs.existsSync(home) && !fs.existsSync(root) && !fs.existsSync(userHome);
362
+ evidence.finishedAt = new Date().toISOString();
363
+ if (!evidence.protectedStateUnchanged || evidence.sourceChangesDuringValidation.length || !evidence.fixtureProcessesStopped || !evidence.nativeProcessStopped ||
364
+ evidence.browserCleanupError || evidence.runtimeCleanupError || evidence.serviceCleanupError) { evidence.completed = false; process.exitCode = 1; }
365
+ fs.writeFileSync(output, JSON.stringify(evidence, null, 2), { mode: 0o600 });
366
+ console.log(JSON.stringify({ evidence: output, completed: evidence.completed, protectedStateUnchanged: evidence.protectedStateUnchanged,
367
+ sourceChangesDuringValidation: evidence.sourceChangesDuringValidation, fixtureProcessesStopped: evidence.fixtureProcessesStopped }));
368
+ }
@@ -0,0 +1,128 @@
1
+ // Browser lifecycle checks against deterministic native adapters, with no model calls.
2
+ import assert from 'node:assert/strict';
3
+
4
+ export async function runNativeHandoffChecks({ browser, base, token, adaptersByPeer, api, service, select, check, shot, watch, browserInstrumentation, evidence, stopWeb }) {
5
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
6
+ await context.addInitScript(browserInstrumentation);
7
+ const peer = 'qa-web-created', submitted = 'Fixture input submitted once before closing Web';
8
+ const draft = 'Unsent background Agent draft survives page close';
9
+ const requests = { launch: [], resume: [] };
10
+ const watchMutations = page => page.on('request', request => {
11
+ if (request.method() !== 'POST') return;
12
+ const pathname = new URL(request.url()).pathname;
13
+ if (pathname === '/api/sessions' && request.postDataJSON()?.id === peer) requests.launch.push(request.postDataJSON());
14
+ if (pathname === '/api/native/history/' + peer + '/resume') requests.resume.push(request.postDataJSON());
15
+ });
16
+ let page = await context.newPage(); watch(page); watchMutations(page);
17
+ try {
18
+ evidence.phase = 'Web background Agent creation and lifecycle';
19
+ await page.goto(base + '/?token=' + token);
20
+ await select(page, 'qa-codex');
21
+ await page.locator('#openStartDialog').click();
22
+ await page.locator('#startDialog').waitFor({ state: 'visible' });
23
+ await page.waitForFunction(() => !document.getElementById('agentStartBtn').disabled);
24
+ assert.equal(await page.locator('#kind').inputValue(), 'codex');
25
+ assert.equal(await page.locator('#transport').inputValue(), 'native');
26
+ await page.locator('#agentName').fill(peer);
27
+ await page.locator('#agentStartBtn').click();
28
+ await page.waitForFunction(peer => window.hccHandoff?.active === peer && window.hccHandoff.canControl &&
29
+ /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent), peer);
30
+ const initial = await api('GET', '/workers/' + peer + '/state');
31
+ const serviceBefore = await api('GET', '/status');
32
+ const original = adaptersByPeer.get(peer);
33
+ assert.ok(original && initial.owner && initial.snapshot.sessionId);
34
+ assert.equal(requests.launch.length, 1);
35
+ assert.equal(requests.launch[0].transport, 'native');
36
+ assert.equal(original.opens.length, 1);
37
+ assert.equal(original.sent || 0, 0);
38
+ await shot(page, 'desktop-web-created-native');
39
+ check('visible New Agent form creates one independent native worker with the default background interface');
40
+
41
+ await page.locator('#nativeDraft').fill(submitted);
42
+ await page.locator('#nativeSend').click();
43
+ await page.waitForFunction(() => document.getElementById('nativeDraft').value === '');
44
+ // The fixture uses a long automatic poll interval; drain the durable queue now.
45
+ await service.poll();
46
+ const active = await api('GET', '/workers/' + peer + '/state');
47
+ assert.equal(original.sent, 1);
48
+ assert.ok(active.active_delivery && active.snapshot.turnId);
49
+ await page.locator('#nativeDraft').fill(draft);
50
+ await page.close();
51
+ original.emit({ type: 'message', itemId: 'background-live-output', text: 'BACKGROUND_EXECUTOR_CONTINUED_WITHOUT_WEB' });
52
+ const detached = await api('GET', '/workers/' + peer + '/state');
53
+ assert.equal(detached.owner, initial.owner);
54
+ assert.equal(detached.generation, initial.generation);
55
+ assert.equal(detached.snapshot.sessionId, initial.snapshot.sessionId);
56
+ assert.equal(detached.snapshot.turnId, active.snapshot.turnId);
57
+ page = await context.newPage(); watch(page); watchMutations(page);
58
+ await page.goto(base + '/?token=' + token);
59
+ await select(page, peer);
60
+ await page.waitForFunction(() => document.getElementById('nativeEvents').textContent.includes('BACKGROUND_EXECUTOR_CONTINUED_WITHOUT_WEB'));
61
+ assert.equal(await page.locator('#nativeDraft').inputValue(), draft);
62
+ assert.equal(original.sent, 1);
63
+ assert.equal(requests.launch.length, 1);
64
+ if (!await page.evaluate(() => window.hccHandoff.canControl)) await page.locator('#claimControlBtn').click();
65
+ await page.waitForFunction(() => window.hccHandoff.canControl);
66
+ await shot(page, 'desktop-web-reconnected-native');
67
+ check('closing and reopening Web preserves the new native owner, provider session, active turn and unsent draft without replay');
68
+
69
+ original.state.status = 'idle'; original.state.turnId = null;
70
+ original.emit({ type: 'completed', status: 'completed', turnId: active.snapshot.turnId });
71
+ await page.locator('#nativeRead').click();
72
+ await page.waitForFunction(() => document.getElementById('nativeInterrupt').disabled);
73
+ await page.locator('#nativeCloseConfirmed').check();
74
+ const closeReply = page.waitForResponse(response => response.request().method() === 'POST' &&
75
+ new URL(response.url()).pathname === '/api/sessions/' + peer + '/native/close');
76
+ await page.locator('#nativeClose').click();
77
+ assert.equal((await closeReply).status(), 200);
78
+ const closed = (await api('GET', '/status')).workers.find(worker => worker.peer === peer);
79
+ assert.equal(closed.status, 'closed'); assert.equal(closed.owned, false);
80
+ await page.locator('#openHistoryDialog').click();
81
+ await page.locator('#historyThreads [data-thread="' + peer + '"]').click();
82
+ await page.waitForFunction(() => document.getElementById('historySelected').textContent.includes('qa-web-created') &&
83
+ !document.getElementById('historyConfirmed').disabled);
84
+ assert.equal(await page.locator('#historyResume').isDisabled(), true);
85
+ await page.locator('#historyConfirmed').check();
86
+ // History confirmation submits restore directly; it does not require a second launch.
87
+ await page.locator('#historyResume').click();
88
+ await page.waitForFunction(peer => document.getElementById('startDialog').hidden &&
89
+ window.hccHandoff?.active === peer && window.hccHandoff.actionToken &&
90
+ /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent), peer);
91
+ const resumed = await api('GET', '/workers/' + peer + '/state');
92
+ const replacement = adaptersByPeer.get(peer);
93
+ assert.notEqual(resumed.owner, initial.owner);
94
+ assert.equal(resumed.snapshot.sessionId, initial.snapshot.sessionId);
95
+ assert.equal(resumed.generation, initial.generation);
96
+ assert.notEqual(replacement, original);
97
+ assert.equal(replacement.opens.length, 1);
98
+ assert.equal(replacement.opens[0].sessionId, initial.snapshot.sessionId);
99
+ assert.equal(replacement.sent || 0, 0);
100
+ assert.equal(requests.launch.length, 1); assert.equal(requests.resume.length, 1);
101
+ assert.equal(await page.locator('#nativeNotice').innerText(), '');
102
+ assert.equal(await page.locator('#nativeCloseConfirmed').isChecked(), false);
103
+ assert.equal(await page.locator('#nativeDraft').inputValue(), draft);
104
+ await shot(page, 'desktop-native-history-resumed');
105
+ check('explicit retained-history confirmation resumes the same provider session in a new native owner without creating or replaying input');
106
+
107
+ // Close every Web view before stopping its HTTP service; native has a separate owner.
108
+ await Promise.all(browser.contexts().map(view => view.close()));
109
+ await stopWeb();
110
+ const serviceAfter = await api('GET', '/status');
111
+ const afterWebShutdown = await api('GET', '/workers/' + peer + '/state');
112
+ assert.equal(serviceAfter.pid, serviceBefore.pid);
113
+ assert.equal(serviceAfter.generation, serviceBefore.generation);
114
+ assert.equal(afterWebShutdown.owner, resumed.owner);
115
+ assert.equal(afterWebShutdown.snapshot.sessionId, resumed.snapshot.sessionId);
116
+ evidence.nativeHandoff = { providerMode: 'synthetic native adapter', nativeRuntimePid: serviceAfter.pid,
117
+ generation: initial.generation, originalOwner: initial.owner, resumedOwner: resumed.owner,
118
+ providerSessionId: resumed.snapshot.sessionId, launchRequests: requests.launch.length,
119
+ resumeRequests: requests.resume.length, originalInputs: original.sent, replayedInputs: replacement.sent || 0,
120
+ webShutdownPreservedNativeRuntime: true };
121
+ check('Web shutdown keeps the independently owned native runtime and resumed worker alive');
122
+ } catch (error) {
123
+ evidence.nativeHandoffFailure = { requests, message: error.message, current: page.isClosed() ? null : await page.evaluate(() => ({ active: window.hccHandoff?.active, project: window.hccHandoff?.projectRoot, canControl: window.hccHandoff?.canControl, startError: document.getElementById('startDialogError').textContent, startHidden: document.getElementById('startDialog').hidden })) };
124
+ evidence.nativeHandoffFailure.runtime = await api('GET', '/status');
125
+ if (!page.isClosed()) await shot(page, 'native-handoff-failure');
126
+ throw error;
127
+ } finally { await context.close(); }
128
+ }