@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,302 @@
1
+ // Reusable browser checks for normal, test-owned project artifacts.
2
+ // Shares the acceptance runtime; never starts an Agent or calls a model.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { deflateSync } from 'node:zlib';
8
+
9
+ export const FILE_PREVIEW_CHECKS = Object.freeze([
10
+ 'standalone', 'html', 'markdown', 'text', 'image', 'pdf', 'formats',
11
+ 'bounded-text', 'mobile', 'late-file', 'close-pending', 'project-switch', 'readonly'
12
+ ]);
13
+
14
+ const sha256 = content => createHash('sha256').update(content).digest('hex');
15
+ function pngFixture() {
16
+ const crc = bytes => {
17
+ let value = -1;
18
+ for (const byte of bytes) {
19
+ value ^= byte;
20
+ for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
21
+ }
22
+ return (value ^ -1) >>> 0;
23
+ };
24
+ const chunk = (name, data) => {
25
+ const type = Buffer.from(name), length = Buffer.alloc(4), checksum = Buffer.alloc(4);
26
+ length.writeUInt32BE(data.length); checksum.writeUInt32BE(crc(Buffer.concat([type, data])));
27
+ return Buffer.concat([length, type, data, checksum]);
28
+ };
29
+ const width = 640, height = 320, rows = Buffer.alloc((width * 3 + 1) * height);
30
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
31
+ const offset = y * (width * 3 + 1) + 1 + x * 3;
32
+ let color = [244, 249, 247];
33
+ if (x > 55 && x < 590 && (y === 272 || y === 273)) color = [182, 204, 196];
34
+ for (let bar = 0; bar < 5; bar++) {
35
+ if (x > 75 + 100 * bar && x < 138 + 100 * bar && y > 215 - bar * 29 && y < 272) color = [19 + bar * 7, 125 + bar * 10, 107 + bar * 6];
36
+ }
37
+ for (let channel = 0; channel < 3; channel++) rows[offset + channel] = color[channel];
38
+ }
39
+ const header = Buffer.alloc(13);
40
+ header.writeUInt32BE(width, 0); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 2;
41
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]);
42
+ }
43
+
44
+ function pdfFixture() {
45
+ const stream = 'BT /F1 25 Tf 54 738 Td (Project report) Tj 0 -45 Td /F1 13 Tf (File preview acceptance fixture) Tj 0 -30 Td (All five sample milestones are complete.) Tj ET';
46
+ const objects = [
47
+ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
48
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
49
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
50
+ '<< /Length ' + Buffer.byteLength(stream) + ' >>\nstream\n' + stream + '\nendstream'
51
+ ];
52
+ let text = '%PDF-1.4\n';
53
+ const offsets = [];
54
+ for (const [index, body] of objects.entries()) {
55
+ offsets.push(Buffer.byteLength(text)); text += (index + 1) + ' 0 obj\n' + body + '\nendobj\n';
56
+ }
57
+ const xref = Buffer.byteLength(text);
58
+ text += 'xref\n0 6\n0000000000 65535 f \n' + offsets.map(offset => String(offset).padStart(10, '0') + ' 00000 n \n').join('')
59
+ + 'trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n' + xref + '\n%%EOF\n';
60
+ return Buffer.from(text);
61
+ }
62
+
63
+ function fixtures(sandbox) {
64
+ const first = path.join(sandbox, 'preview project with spaces'), second = path.join(sandbox, 'second preview project');
65
+ const files = new Map([
66
+ [path.join(first, 'README.md'), '# Project handoff\n\nA **reviewable result** from the workspace.\n\n- Inspect the report\n- Review the chart\n- Read the source\n\n```js\nconst status = "complete";\n```\n'],
67
+ [path.join(first, 'src/main.mjs'), 'export const greeting = "Hello from the project";\nexport const example = "<sample>";\n'],
68
+ [path.join(first, 'artifacts/report.html'), '<!doctype html><html><head><title>Milestone report</title><style>body{font:16px system-ui;margin:0;padding:40px;background:#f5faf7;color:#174837}h1{font-size:32px}section{padding:22px;background:white;border:1px solid #cce2d5;border-radius:12px;margin-top:28px}strong{font-size:40px;color:#12876b}</style></head><body><h1>Milestone report</h1><p>Generated locally</p><section><strong>5 / 5</strong><h2>Milestones completed</h2><p>Inspect this saved artifact without starting an Agent.</p></section></body></html>'],
69
+ [path.join(first, 'artifacts/chart.png'), pngFixture()],
70
+ [path.join(first, 'artifacts/report.pdf'), pdfFixture()],
71
+ [path.join(first, 'artifacts/sample.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><text x="20" y="20">Source only</text></svg>'],
72
+ [path.join(first, 'artifacts/data.bin'), Buffer.from([0, 1, 2, 255, 0, 4])],
73
+ [path.join(first, 'large.txt'), 'Large text fixture\n' + 'example line\n'.repeat(100000)],
74
+ [path.join(first, 'dist/index.html'), '<h1>Build output is browsable</h1>'],
75
+ [path.join(second, 'SECOND.md'), '# Second project\n\nThe selected project owns this preview.\n']
76
+ ]);
77
+ for (const [filename, content] of files) {
78
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 }); fs.writeFileSync(filename, content);
79
+ }
80
+ return { first, second, files };
81
+ }
82
+
83
+ function deferred() {
84
+ let resolve, reject;
85
+ const promise = new Promise((accept, decline) => { resolve = accept; reject = decline; });
86
+ return { promise, resolve, reject };
87
+ }
88
+
89
+ async function bounded(promise, label) {
90
+ let timer;
91
+ try {
92
+ return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Timed out: ' + label)), 15000); })]);
93
+ } finally { clearTimeout(timer); }
94
+ }
95
+
96
+ // Delay delivery of an actual response. File contents and server behavior are
97
+ // unchanged; the only controlled variable is when the browser receives it.
98
+ async function delayedPreview(page, project, filename, whileHeld) {
99
+ const arrived = deferred(), release = deferred(), settled = deferred(), completed = deferred();
100
+ let heldRequest, routeError;
101
+ const matches = url => url.pathname === '/api/files/preview' && url.searchParams.get('path') === filename && url.searchParams.get('root') === project;
102
+ const finished = request => { if (request === heldRequest) completed.resolve(); };
103
+ const handler = async route => {
104
+ heldRequest = route.request();
105
+ try {
106
+ const response = await route.fetch();
107
+ assert.equal(response.status(), 200, 'The delayed preview must be a real successful file read');
108
+ arrived.resolve(); await release.promise; await route.fulfill({ response });
109
+ } catch (error) { routeError = error; arrived.reject(error); }
110
+ finally { settled.resolve(); }
111
+ };
112
+ page.on('requestfinished', finished); page.on('requestfailed', finished);
113
+ await page.route(matches, handler);
114
+ try {
115
+ await page.evaluate(filename => window.hccFiles.open(filename), filename);
116
+ await bounded(arrived.promise, 'preview response fetched');
117
+ await whileHeld(); release.resolve();
118
+ await bounded(settled.promise, 'preview response released');
119
+ await bounded(completed.promise, 'preview request settled in browser');
120
+ if (routeError && heldRequest.failure()?.errorText !== 'net::ERR_ABORTED') throw routeError;
121
+ // Wait for the browser's next rendered state, not an arbitrary sleep.
122
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
123
+ return heldRequest.failure() ? 'cancelled-on-project-change' : 'delivered';
124
+ } finally {
125
+ release.resolve(); await page.unroute(matches, handler);
126
+ page.off('requestfinished', finished); page.off('requestfailed', finished);
127
+ }
128
+ }
129
+
130
+ export async function runFilePreviewChecks({ browser, base, token, sandbox, registerPage, adapters, check, shot, watch, browserInstrumentation, evidence }) {
131
+ const { first, second, files } = fixtures(sandbox);
132
+ const hashes = () => Object.fromEntries([...files.keys()].map(filename => [path.relative(sandbox, filename), sha256(fs.readFileSync(filename))]));
133
+ const before = hashes(), providers = [...adapters.keys()], sends = providers.map(provider => adapters.get(provider).sent || 0);
134
+ const receipt = evidence.filePreview = {
135
+ schemaVersion: 1, checks: [], viewports: [{ width: 1440, height: 1000 }, { width: 390, height: 844 }],
136
+ modelCalls: false, fileHashes: before, fileRequests: [], writeRequests: [],
137
+ pdfScope: 'Typed PDF Blob, iframe handoff and downloaded bytes; browser PDF viewer internals and OS rendering are not asserted'
138
+ };
139
+ const pass = (id, label) => { receipt.checks.push(id); check('file preview: ' + label); };
140
+ const previousProject = await registerPage.evaluate(() => window.hccHandoff.projectRoot);
141
+ for (const project of [first, second]) {
142
+ await registerPage.locator('#openProjectDialog').click();
143
+ await registerPage.locator('#projectPath').fill(project);
144
+ await registerPage.locator('#addProjectBtn').click();
145
+ await registerPage.waitForFunction(project => window.hccHandoff.projectRoot === project &&
146
+ document.getElementById('projectDialog').hidden, project);
147
+ }
148
+ await registerPage.locator('#projectSelect').selectOption(previousProject);
149
+ await registerPage.waitForFunction(project => window.hccHandoff.projectRoot === project &&
150
+ window.hccHandoff.actionToken && /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent), previousProject);
151
+ const context = await browser.newContext({ viewport: receipt.viewports[0], acceptDownloads: true });
152
+ await context.addInitScript(browserInstrumentation);
153
+ // Observe the normal browser Blob handoff. Fetching a blob: URL is outside
154
+ // the preview flow and intentionally unavailable under the page's CSP.
155
+ await context.addInitScript(() => {
156
+ const create = URL.createObjectURL;
157
+ window.__hccPreviewBlobs = new Map();
158
+ URL.createObjectURL = function (blob) {
159
+ const url = create.call(this, blob);
160
+ window.__hccPreviewBlobs.set(url, { type: blob.type, size: blob.size });
161
+ return url;
162
+ };
163
+ });
164
+ const page = await context.newPage(); watch(page);
165
+ const open = async () => {
166
+ await page.locator('#filesBtn').click(); await page.locator('#filesDialog').waitFor({ state: 'visible' });
167
+ await page.locator('#filesEntries button').first().waitFor();
168
+ };
169
+ const select = async filename => {
170
+ const buttons = page.locator('#filesEntries button[data-file-path]');
171
+ const index = (await buttons.evaluateAll(elements => elements.map(element => element.dataset.filePath))).indexOf(filename);
172
+ assert.ok(index >= 0, 'Missing project entry: ' + filename); await buttons.nth(index).click();
173
+ };
174
+ const show = async filename => {
175
+ await page.evaluate(filename => window.hccFiles.open(filename), filename);
176
+ await page.waitForFunction(filename => document.getElementById('filesMeta').textContent.startsWith(filename + ' ·'), filename);
177
+ };
178
+ const fits = async () => {
179
+ const box = await page.locator('#filesPanel').boundingBox(), viewport = page.viewportSize();
180
+ assert.ok(box && box.x >= 0 && box.y >= 0 && box.x + box.width <= viewport.width + 1 && box.y + box.height <= viewport.height + 1, JSON.stringify(box));
181
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
182
+ };
183
+ try {
184
+ await page.goto(base + '/?token=' + token);
185
+ await page.waitForFunction(() => window.hccFiles && window.hccHandoff?.actionToken &&
186
+ /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent));
187
+ await page.locator('#projectSelect').selectOption(first);
188
+ await page.waitForFunction(project => window.hccHandoff?.projectRoot === project, first);
189
+ assert.equal(await page.title(), 'hello-cc'); assert.equal(new URL(page.url()).origin, base);
190
+ assert.ok((await page.locator('body').innerText()).includes('hello-cc'));
191
+ assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
192
+ page.on('request', request => {
193
+ const url = new URL(request.url());
194
+ if (url.origin !== base || !url.pathname.startsWith('/api/')) return;
195
+ const item = { method: request.method(), path: url.pathname, project: url.searchParams.get('root') };
196
+ if (url.pathname.startsWith('/api/files/')) receipt.fileRequests.push(item);
197
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) receipt.writeRequests.push(item);
198
+ });
199
+ await open(); await fits();
200
+ const names = await page.locator('#filesEntries button').evaluateAll(buttons => buttons.map(button => button.dataset.filePath));
201
+ assert.ok(names.includes('README.md') && names.includes('dist'));
202
+ assert.equal(await page.evaluate(() => window.hccHandoff.sessions.length), 0);
203
+ pass('standalone', 'standalone project files open with no Agent session');
204
+
205
+ await select('artifacts'); await page.locator('[data-file-path="artifacts/report.html"]').waitFor();
206
+ await select('artifacts/report.html'); await page.frameLocator('#filesFrame').locator('h1').waitFor();
207
+ assert.equal(await page.frameLocator('#filesFrame').locator('h1').innerText(), 'Milestone report');
208
+ assert.equal(await page.locator('#filesFrame').getAttribute('sandbox'), '');
209
+ await shot(page, 'desktop-file-preview-html');
210
+ await page.locator('#filesSourceBtn').click();
211
+ assert.ok((await page.locator('#filesPreview pre').innerText()).includes('<h1>Milestone report</h1>'));
212
+ await page.locator('#filesSourceBtn').click(); await page.frameLocator('#filesFrame').locator('h1').waitFor();
213
+ pass('html', 'directory navigation and static HTML source/rendered switch work');
214
+
215
+ await page.locator('#filesUp').click(); await page.locator('[data-file-path="README.md"]').waitFor();
216
+ await select('README.md'); await page.locator('#filesPreview h1').waitFor();
217
+ assert.equal(await page.locator('#filesPreview h1').innerText(), 'Project handoff');
218
+ assert.equal(await page.locator('#filesPreview li').count(), 3);
219
+ assert.equal(await page.locator('#filesPreview pre code').innerText(), 'const status = "complete";');
220
+ pass('markdown', 'Markdown headings, lists and code blocks render');
221
+ await show('src/main.mjs');
222
+ assert.ok((await page.locator('#filesPreview pre').innerText()).includes('<sample>'));
223
+ assert.equal(await page.locator('#filesPreview sample').count(), 0);
224
+ pass('text', 'source files display literal text');
225
+
226
+ await show('artifacts/chart.png');
227
+ await page.waitForFunction(() => document.querySelector('#filesPreview img')?.naturalWidth === 640);
228
+ const size = await page.locator('#filesPreview img').evaluate(image => ({ width: image.naturalWidth, height: image.naturalHeight, renderedWidth: image.clientWidth, renderedHeight: image.clientHeight }));
229
+ assert.equal(size.height, 320); assert.ok(Math.abs(size.renderedWidth / size.renderedHeight - 2) < 0.02);
230
+ await shot(page, 'desktop-file-preview-image');
231
+ pass('image', 'PNG dimensions and displayed aspect ratio match the fixture');
232
+
233
+ await show('artifacts/report.pdf'); await page.locator('#filesDownload').waitFor({ state: 'visible' });
234
+ const pdfUrl = await page.locator('#filesFrame').getAttribute('src'); assert.match(pdfUrl, /^blob:/);
235
+ assert.equal(await page.locator('#filesDownload').getAttribute('href'), pdfUrl);
236
+ const blob = await page.evaluate(url => window.__hccPreviewBlobs.get(url), pdfUrl);
237
+ assert.equal(blob.type, 'application/pdf');
238
+ const expectedPdfHash = sha256(files.get(path.join(first, 'artifacts/report.pdf')));
239
+ assert.equal(blob.size, files.get(path.join(first, 'artifacts/report.pdf')).length);
240
+ const downloading = page.waitForEvent('download'); await page.locator('#filesDownload').click(); const download = await downloading;
241
+ assert.equal(download.suggestedFilename(), 'report.pdf');
242
+ const stream = await download.createReadStream(), chunks = [];
243
+ assert.ok(stream, 'Downloaded PDF must be readable'); for await (const chunk of stream) chunks.push(chunk);
244
+ assert.equal(sha256(Buffer.concat(chunks)), expectedPdfHash);
245
+ receipt.pdf = { mime: blob.type, bytes: blob.size, sha256: expectedPdfHash, filename: download.suggestedFilename() };
246
+ await shot(page, 'desktop-file-preview-pdf-handoff');
247
+ pass('pdf', 'typed PDF Blob and downloaded bytes match the saved file');
248
+
249
+ await show('artifacts/sample.svg'); assert.ok((await page.locator('#filesPreview pre').innerText()).includes('<svg'));
250
+ assert.equal(await page.locator('#filesPreview svg').count(), 0);
251
+ await show('artifacts/data.bin'); assert.ok((await page.locator('#filesPreview').innerText()).trim().length > 0);
252
+ assert.equal(await page.locator('#filesPreview iframe,#filesPreview img').count(), 0);
253
+ pass('formats', 'SVG remains source text and unsupported binary has an explicit state');
254
+ await show('large.txt'); assert.ok((await page.locator('#filesPreviewStatus').innerText()).trim().length > 0);
255
+ const large = await page.locator('#filesPreview pre').innerText();
256
+ assert.ok(Buffer.byteLength(large) <= 1024 * 1024 && large.length < String(files.get(path.join(first, 'large.txt'))).length);
257
+ pass('bounded-text', 'large text remains bounded with a visible truncation notice');
258
+
259
+ await page.locator('#filesClose').click(); await page.setViewportSize(receipt.viewports[1]);
260
+ await open(); await fits(); await shot(page, 'mobile-file-preview-list');
261
+ await select('README.md'); await page.locator('#filesPreview h1').waitFor(); await fits();
262
+ await shot(page, 'mobile-file-preview-markdown');
263
+ await page.locator('#filesBack').click(); assert.equal(await page.locator('#filesEntries').isVisible(), true);
264
+ await page.locator('#filesClose').click(); assert.equal(await page.locator('#filesDialog').isVisible(), false);
265
+ pass('mobile', '390px list, preview, back and close fit the viewport');
266
+
267
+ await page.setViewportSize(receipt.viewports[0]);
268
+ receipt.lateFile = await delayedPreview(page, first, 'src/main.mjs', () => show('README.md'));
269
+ assert.equal(await page.locator('#filesName').innerText(), 'README.md');
270
+ assert.equal(await page.locator('#filesPreview h1').innerText(), 'Project handoff');
271
+ pass('late-file', 'late previous-file response cannot replace the latest preview');
272
+ await delayedPreview(page, first, 'src/main.mjs', () => page.locator('#filesClose').click());
273
+ assert.equal(await page.locator('#filesDialog').isVisible(), false);
274
+ assert.equal(await page.locator('#filesPreview').innerText(), '');
275
+ await open(); assert.equal(await page.locator('#filesName').innerText(), '');
276
+ pass('close-pending', 'closing a pending preview leaves the next dialog clean');
277
+
278
+ receipt.lateProject = await delayedPreview(page, first, 'src/main.mjs', async () => {
279
+ // The project selector is outside the modal. Close through its visible
280
+ // control before switching, as a user would.
281
+ await page.locator('#filesClose').click();
282
+ await page.locator('#projectSelect').selectOption(second);
283
+ await page.waitForFunction(project => window.hccHandoff.projectRoot === project, second);
284
+ await open();
285
+ const entries = await page.locator('#filesEntries button').evaluateAll(buttons => buttons.map(button => button.dataset.filePath));
286
+ assert.ok(entries.includes('SECOND.md') && !entries.includes('README.md'));
287
+ await select('SECOND.md'); await page.locator('#filesPreview h1').waitFor();
288
+ });
289
+ assert.equal(await page.locator('#filesProject').innerText(), second);
290
+ assert.equal(await page.locator('#filesPreview h1').innerText(), 'Second project');
291
+ pass('project-switch', 'project switch scopes reads and fences the prior project response');
292
+ await page.locator('#filesClose').click();
293
+ assert.equal(await page.evaluate(() => window.hccHandoff.sessions.length), 0);
294
+ assert.deepEqual([...adapters.keys()], providers); assert.deepEqual(providers.map(provider => adapters.get(provider).sent || 0), sends);
295
+ assert.deepEqual(hashes(), before);
296
+ assert.deepEqual(receipt.writeRequests, [{ method: 'POST', path: '/api/projects/select', project: second }],
297
+ 'Only the explicit project switch may write selection metadata; previews must not mutate files or workers');
298
+ assert.ok(receipt.fileRequests.length > 0 && receipt.fileRequests.every(request => request.method === 'GET' && [first, second].includes(request.project)));
299
+ pass('readonly', 'preview preserves fixture bytes and makes no file writes or Agent sends');
300
+ assert.deepEqual(receipt.checks, FILE_PREVIEW_CHECKS); receipt.success = true;
301
+ } finally { await context.close(); }
302
+ }
@@ -0,0 +1,134 @@
1
+ // Browser acceptance with isolated simulated providers. No real model or provider authentication is used.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import net from 'node:net';
5
+ import assert from 'node:assert/strict';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { createHash } from 'node:crypto';
10
+ if (!process.argv.includes('--run-browser')) { console.log('Use --run-browser with HCC_ACCEPTANCE_PLAYWRIGHT and optional HCC_ACCEPTANCE_CHROME. Uses simulated providers and isolated projects; no model calls.'); process.exit(0); }
11
+ const { chromium } = await import(process.env.HCC_ACCEPTANCE_PLAYWRIGHT || 'playwright');
12
+ import { createEventHelpers } from '../lib/db/events.mjs';
13
+ import { createPeerHelpers } from '../lib/core/peers/peer-helpers.mjs';
14
+ import { createPeerBindingStore } from '../lib/db/stores/peers.mjs';
15
+ import { createMessageStore } from '../lib/core/coordination/messages.mjs';
16
+ import { inspectProcessIdentity } from '../lib/process/identity.mjs';
17
+ import { startNativeService } from '../lib/runtime/native/service.mjs';
18
+ import { nativeRequest } from '../lib/runtime/native/client.mjs';
19
+ import { createNativeInteractions } from '../lib/integrations/native/interactions.mjs';
20
+ import { codexInteractionResponse, codexInteractionKind, cancelCodexInteraction } from '../lib/integrations/codex-interactions.mjs';
21
+ const node=process.execPath, repo=fileURLToPath(new URL('..',import.meta.url));
22
+ const dir=fs.realpathSync(fs.mkdtempSync('/tmp/hcc-interactions-ui-')),root=path.join(dir,'project'),home=path.join(dir,'home'),bin=path.join(dir,'bin'),socket='hcc-interactions-'+Date.now();
23
+ for(const folder of [root,home,bin])fs.mkdirSync(folder);
24
+ const formParams={mode:'form',serverName:'form-fixture',message:'Provide project settings <script>bad()</script>',requestedSchema:{type:'object',properties:{name:{type:'string',title:'Project <name>',minLength:3,maxLength:20},count:{type:'integer',minimum:1,maximum:4},enabled:{type:'boolean'},region:{type:'string',oneOf:[{const:'eu',title:'Europe'},{const:'us',title:'USA'}]},features:{type:'array',minItems:1,maxItems:2,items:{anyOf:[{const:'logs',title:'Logs'},{const:'tests',title:'Tests'}]}},optional:{type:'string',default:'suggested'}},required:['name','count','enabled','region','features']}};
25
+ const env={PATH:bin+':'+path.dirname(node)+':/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin',HOME:home,SHELL:'/bin/bash',LANG:'en_US.UTF-8',TMPDIR:dir,CODEX_HOME:path.join(home,'.codex'),HCC_NO_AUTO_INSTALL_TMUX:'1',HCC_WEB_TOKEN:'qa-interactive-isolated',HCC_TEST_MCP_RPC:path.join(dir,'mcp-rpc.jsonl')};
26
+ const tmux=spawnSync('which',['tmux'],{encoding:'utf8'}).stdout.trim();if(!tmux||!fs.existsSync(tmux))throw Error('tmux is required for isolated Web runtime acceptance');
27
+ fs.writeFileSync(path.join(bin,'tmux'),'#!/bin/sh\nexec '+JSON.stringify(tmux)+' -L '+socket+' "$@"\n',{mode:0o700});
28
+ fs.writeFileSync(path.join(bin,'codex'),`#!${node}
29
+ import readline from 'node:readline';
30
+ import fs from 'node:fs';
31
+ const send=x=>process.stdout.write(JSON.stringify(x)+'\\n'),reply=(m,r)=>send({id:m.id,result:r});
32
+ let thread={id:'qa-thread',cwd:process.cwd(),status:{type:'idle'},turns:[]}, pending, turn;
33
+ const req=(method,params)=>{pending=params.mode==='url'?'mcp-url':method;send({id:params.mode==='url'?'mcp-url':method==='mcpServer/elicitation/request'?0:method,method,params:{threadId:thread.id,turnId:turn.id,itemId:method,...params}})};
34
+ readline.createInterface({input:process.stdin}).on('line',line=>{
35
+ const m=JSON.parse(line),p=m.params||{};
36
+ if(!m.method){fs.appendFileSync(process.env.HCC_TEST_MCP_RPC,JSON.stringify(m)+'\\n');send({method:'serverRequest/resolved',params:{threadId:thread.id,requestId:m.id}});if(pending==='item/permissions/requestApproval'){req('item/tool/requestUserInput',{questions:[{id:'choice',header:'Choice',question:'Which mode?',options:[{label:'Blue',description:'Blue mode'},{label:'Red',description:'Red mode'}]}]});}else if(pending==='item/tool/requestUserInput'){req('mcpServer/elicitation/request',${JSON.stringify(formParams)});}else if(pending==='mcpServer/elicitation/request'){req('mcpServer/elicitation/request',{mode:'url',serverName:'form-fixture',message:'External authentication',url:'javascript:alert(1)',elicitationId:'unsupported-url'});}else{turn.status='completed';send({method:'turn/completed',params:{threadId:thread.id,turn}});}return;}
37
+ if(m.method==='initialized')return;
38
+ if(m.method==='initialize')return reply(m,{userAgent:'ui-fake'});
39
+ if(m.method==='thread/list')return reply(m,{data:[thread],nextCursor:null});
40
+ if(['thread/start','thread/resume','thread/read'].includes(m.method))return reply(m,{thread});
41
+ if(m.method==='turn/start'){turn={id:'qa-turn',status:'inProgress',items:[]};thread.turns.push(turn);thread.status={type:'active'};reply(m,{turn});req('item/permissions/requestApproval',{cwd:process.cwd(),reason:'Test bounded permission',permissions:{network:{enabled:true},fileSystem:{read:['/read-only'],write:['/project']}}});return;}
42
+ reply(m,{});
43
+ });
44
+ `,{mode:0o700});
45
+ function sourceHashes(){const result={};const walk=folder=>{for(const entry of fs.readdirSync(path.join(repo,folder),{withFileTypes:true})){const file=path.join(folder,entry.name);if(entry.isDirectory())walk(file);else if(entry.isFile())result[file]=createHash('sha256').update(fs.readFileSync(path.join(repo,file))).digest('hex');}};for(const folder of ['bin','lib','scripts','test'])walk(folder);for(const file of ['package.json','package-lock.json'])result[file]=createHash('sha256').update(fs.readFileSync(path.join(repo,file))).digest('hex');return result;}
46
+ const evidence={dir,checks:[],screenshots:[],pageErrors:[],consoleErrors:[],browser:'Browser plugin not available; isolated installed Chrome + Playwright',providerMode:'simulated protocol requests; production HTTP, SQLite, worker and browser paths; no inference',node:process.version,sourceRoot:repo,sourceFiles:sourceHashes(),startedAt:new Date().toISOString()};
47
+ const check=(name,extra={})=>{evidence.checks.push({name,...extra});console.log('PASS '+name);};
48
+ function hcc(...args){const r=spawnSync(node,[path.join(repo,'bin/hcc.mjs'),'--root',root,...args],{cwd:root,env,encoding:'utf8',timeout:30000});if(r.status!==0)throw Error('Isolated CLI failed '+args[0]);return r.stdout;}
49
+ const ctx={root,dbPath:path.join(root,'.hello-cc','mesh.db')},events=createEventHelpers(),binding=createPeerBindingStore(events),peer=createPeerHelpers({now:()=>Math.floor(Date.now()/1000),...events});
50
+ const deps={...events,...binding,...peer,...createMessageStore(events),connect:()=>new DatabaseSync(ctx.dbPath),detectBranch:()=>'',liveProcessIdentity:pid=>inspectProcessIdentity(pid).identity};
51
+ let browser,service,runtime=false,state,interactions,emit,adapter;
52
+ const api=(method,route,body)=>nativeRequest(ctx,method,route,body);
53
+ const port=await new Promise(resolve=>{const s=net.createServer();s.listen(0,'127.0.0.1',()=>{const p=s.address().port;s.close(()=>resolve(p));});}),base='http://127.0.0.1:'+port;
54
+ const shot=async(page,name)=>{const file=path.join(dir,name+'.png');await page.screenshot({path:file});evidence.screenshots.push(file);};
55
+ const wait=(page,fn)=>page.waitForFunction(fn,null,{timeout:12000});
56
+ function ask(id,method,params){state.turnId='native-turn';state.status='running';return interactions.request({requestId:id,sessionId:state.sessionId,turnId:state.turnId,method,kind:codexInteractionKind(method),params,validate:codexInteractionResponse,cancelled:cancelCodexInteraction(method)});}
57
+ try{
58
+ hcc('up','--no-discover','--no-guidance');
59
+ service=await startNativeService(ctx,deps,{pollMs:60000,adapterFactory:async(provider,options)=>{
60
+ state={provider,status:'idle',sessionId:'native-qa-session',turnId:null,executorId:options.executorId,capabilities:{send:true,interrupt:true,close:true,approvals:true,userInput:true}};emit=options.onEvent;
61
+ interactions=createNativeInteractions({executorId:options.executorId,isActive:(session,turn)=>session===state.sessionId&&turn===state.turnId&&state.status==='running',onChange:emit});
62
+ adapter={capabilities:state.capabilities,snapshot:()=>({...state,pendingApprovals:interactions.snapshot()}),async open(){return this.snapshot();},respond:interactions.respond,async interrupt(){interactions.expire();state.turnId=null;state.status='idle';return{status:'interrupting'};},async close(){interactions.expire();state.status='closed';}};return adapter;
63
+ }});
64
+ await api('POST','/workers',{peer:'native-qa',provider:'codex'});
65
+ hcc('web','--local','--port',String(port),'--no-discover','--no-guidance');runtime=true;
66
+ browser=await chromium.launch({headless:true,executablePath:process.env.HCC_ACCEPTANCE_CHROME||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
67
+ const a=await browser.newContext({viewport:{width:1440,height:1000}}),b=await browser.newContext({viewport:{width:1280,height:900}});
68
+ for(const c of [a,b])await c.addInitScript(()=>localStorage.setItem('hcc.lang','en'));
69
+ const p1=await a.newPage(),p2=await b.newPage();
70
+ for(const [p,label]of [[p1,'controller'],[p2,'observer']]){p.on('pageerror',e=>evidence.pageErrors.push({label,message:e.message}));p.on('console',m=>{if(['warning','error'].includes(m.type()))evidence.consoleErrors.push({label,text:m.text()});});p.on('dialog',d=>d.accept());}
71
+ await p1.goto(base+'/?token='+env.HCC_WEB_TOKEN);await wait(p1,()=>window.hccHandoff?.canControl&&window.hccHandoff.session?.type==='native');
72
+ assert.equal(await p1.title(),'hello-cc');assert.ok((await p1.locator('body').innerText()).length>200);check('page identity, nonblank, native view and no error overlay');
73
+ const command=ask('command','item/commandExecution/requestApproval',{command:'echo <script>bad()</script>',cwd:root});
74
+ await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals button[data-decision="accept"]').waitFor();
75
+ await p2.goto(base+'/?token='+env.HCC_WEB_TOKEN);await wait(p2,()=>window.hccHandoff?.session?.type==='native'&&window.hccHandoff.actionToken);
76
+ assert.equal(await p2.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);assert.equal(await p1.locator('#nativeApprovals script').count(),0);check('observer approvals disabled and request previews escape HTML');
77
+ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await command,{decision:'accept'});await wait(p1,()=>!document.querySelector('#nativeApprovals button'));check('manual command approval resolves the original request and removes its card');
78
+ const permissions=ask('permissions','item/permissions/requestApproval',{permissions:{network:{enabled:true},fileSystem:{read:['/read-only'],write:['/write-only']}}});
79
+ await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals [data-permission="read"]').check();
80
+ await shot(p1,'native-permissions-desktop');
81
+ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await permissions,{permissions:{fileSystem:{read:['/read-only']}},scope:'turn'});check('permissions submit only selected read path with explicit turn duration');
82
+ const questions=ask('questions','item/tool/requestUserInput',{questions:[{id:'color',header:'Color',question:'Choose color',isOther:true,options:[{label:'Blue',description:'Blue option'},{label:'Red',description:'Red option'}]},{id:'secret',header:'Secret',question:'Private input',isSecret:true}]});
83
+ await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals button[data-decision="accept"]').click();await wait(p1,()=>document.getElementById('nativeNotice').textContent.includes('answer every question'));assert.equal(interactions.snapshot().length,1);check('unanswered questions remain pending');
84
+ await p1.locator('#nativeApprovals [data-question="0"]').selectOption('other');await p1.locator('#nativeApprovals [data-answer="0"]').fill('Green');await p1.locator('#nativeApprovals [data-answer="1"]').fill('private-qa-answer');
85
+ await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals [data-answer="0"]').inputValue(),'Green');assert.equal(await p1.locator('#nativeApprovals [data-answer="1"]').inputValue(),'private-qa-answer');check('question draft and secret input survive state refresh without local storage');
86
+ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();const answers=await questions;assert.deepEqual(JSON.parse(JSON.stringify(answers)),{answers:{color:{answers:['Green']},secret:{answers:['private-qa-answer']}}});
87
+ assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('private-qa-answer')),false);assert.equal(JSON.stringify(await api('GET','/workers/native-qa/state')).includes('private-qa-answer'),false);check('choice/freeform/secret response shape is correct and answers are absent from events/storage');
88
+ const form=ask('mcp-form','mcpServer/elicitation/request',formParams);
89
+ await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals [data-mcp-field="0"]').waitFor();
90
+ assert.equal(await p1.locator('#nativeApprovals script').count(),0);assert.ok((await p1.locator('#nativeApprovals').innerText()).includes('Project <name>'));
91
+ await p1.locator('#nativeApprovals [data-mcp-field="0"]').fill('private-ui-form');await p1.locator('#nativeApprovals [data-mcp-field="1"]').fill('0');
92
+ await p1.locator('#nativeApprovals [data-mcp-field="2"]').selectOption('1');await p1.locator('#nativeApprovals [data-mcp-field="3"]').selectOption('0');await p1.locator('#nativeApprovals [data-mcp-field="4"][data-mcp-option="1"]').check();
93
+ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();await p1.waitForFunction(()=>document.getElementById('nativeNotice').textContent.includes('Invalid value'));assert.equal(interactions.snapshot().length,1);check('MCP invalid values stay pending and malicious schema labels remain text');
94
+ await p1.locator('#nativeApprovals [data-mcp-field="1"]').fill('2');await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals [data-mcp-field="0"]').inputValue(),'private-ui-form');assert.equal(await p1.locator('#nativeApprovals [data-mcp-field="4"][data-mcp-option="1"]').isChecked(),true);check('MCP text and multiselect drafts survive state refresh in page memory');
95
+ await p1.evaluate(()=>{document.querySelector('.native-scroll').scrollTop=0;});await shot(p1,'native-mcp-form-desktop');await p1.setViewportSize({width:390,height:844});assert.equal(await p1.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),true);await p1.evaluate(()=>{document.querySelector('.native-scroll').scrollTop=0;});await shot(p1,'native-mcp-form-mobile');await p1.locator('#nativeApprovals button[data-decision="accept"]').scrollIntoViewIfNeeded();await shot(p1,'native-mcp-form-mobile-submit');check('MCP form controls fit desktop and mobile viewports');
96
+ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await form,{action:'accept',content:{name:'private-ui-form',count:2,enabled:false,region:'eu',features:['tests']}});
97
+ assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('private-ui-form')),false);assert.equal(JSON.stringify(await api('GET','/workers/native-qa/state')).includes('private-ui-form'),false);check('MCP typed response reaches the original native request, optional defaults are omitted and answers are not retained');await p1.setViewportSize({width:1440,height:1000});
98
+ const unsupported=ask('unsupported-url','mcpServer/elicitation/request',{mode:'url',serverName:'form-fixture',message:'Authenticate',url:'https://auth.example.invalid'});await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p1.locator('#nativeApprovals button[data-decision="cancel"]').click();assert.deepEqual(await unsupported,{action:'cancel'});check('MCP URL request missing its elicitation identity has blocked acceptance and usable cancellation');
99
+ const malformed=ask('unsupported-schema','mcpServer/elicitation/request',{...formParams,requestedSchema:{type:'object',properties:{nested:{type:'object'}}}});await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p1.locator('#nativeApprovals button[data-decision="decline"]').click();assert.deepEqual(await malformed,{action:'decline'});check('unsupported MCP schema remains explicitly rejectable');
100
+ // A valid URL flow uses the same controls, with an isolated intercepted destination.
101
+ await a.route('https://auth.example.invalid/device*',route=>route.fulfill({status:200,contentType:'text/html',body:'<title>Synthetic authorization</title><h1>Complete the external flow</h1>'}));
102
+ const urlFlow=ask('url-flow','mcpServer/elicitation/request',{mode:'url',serverName:'form-fixture',message:'Synthetic external authorization',elicitationId:'private-ui-flow',url:'https://auth.example.invalid/device?code=private-ui-url-code'});
103
+ await p1.locator('#nativeRead').click();await p2.locator('#nativeRead').click();
104
+ assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isEnabled(),true);
105
+ assert.equal(await p2.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);
106
+ assert.equal((await p1.locator('#nativeApprovals').innerText()).includes('private-ui-url-code'),false);
107
+ await p1.evaluate(()=>{window.hccTestOpen=window.open;window.open=()=>null;});
108
+ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();
109
+ await wait(p1,()=>document.getElementById('nativeNotice').textContent.includes('pop-ups'));
110
+ assert.equal(interactions.snapshot().length,1);
111
+ await p1.evaluate(()=>{window.open=window.hccTestOpen;delete window.hccTestOpen;});
112
+ await shot(p1,'native-mcp-url-desktop');
113
+ const popupWait=a.waitForEvent('page');await p1.locator('#nativeApprovals button[data-decision="accept"]').click();const popup=await popupWait;
114
+ await popup.waitForURL('https://auth.example.invalid/device?code=private-ui-url-code');
115
+ assert.deepEqual(await urlFlow,{action:'accept'});assert.equal(await popup.evaluate(()=>window.opener),null);await popup.close();
116
+ const urlState=await api('GET','/workers/native-qa/state');
117
+ assert.equal(JSON.stringify(urlState).includes('private-ui-url-code'),false);assert.equal(JSON.stringify(urlState).includes('private-ui-flow'),false);
118
+ assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('private-ui-url-code')),false);
119
+ check('valid URL opens only for the controller after exact acceptance; popup blocking preserves the request and links are not retained');
120
+ const takeover=ask('takeover','mcpServer/elicitation/request',{mode:'form',requestedSchema:{type:'object',properties:{enabled:{type:'boolean'}},required:['enabled']}});await p1.locator('#nativeRead').click();await p2.locator('#nativeRead').click();await p2.locator('#claimControlBtn').click();await wait(p2,()=>window.hccHandoff.canControl);await wait(p1,()=>!window.hccHandoff.canControl);
121
+ assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p2.locator('#nativeApprovals [data-mcp-field="0"]').selectOption('0');await p2.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await takeover,{action:'accept',content:{enabled:true}});check('lease takeover fences the old browser and enables the new controller');
122
+ const mobile=ask('mobile','item/permissions/requestApproval',{permissions:{network:{enabled:true},fileSystem:{read:['/long/path/with-a-readable-file-name'],write:['/work']}}});
123
+ await p2.setViewportSize({width:390,height:844});await p2.locator('#nativeRead').click();await p2.locator('#nativeApprovals [data-permission="read"]').waitFor();
124
+ assert.equal(await p2.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),true);await p2.evaluate(()=>{const el=document.querySelector('.header-controls');el.scrollLeft=el.scrollWidth;});assert.equal(await p2.evaluate(()=>document.getElementById('logoutBtn').getBoundingClientRect().right<=innerWidth),true);await shot(p2,'native-permissions-mobile');await p2.locator('#nativeApprovals button[data-decision="decline"]').click();assert.deepEqual(await mobile,{permissions:{},scope:'turn'});check('mobile permission controls wrap without page overflow and can decline');
125
+ await p1.locator('#openHistoryDialog').click();await p1.locator('#historyThreads [data-thread="qa-thread"]').waitFor();await p1.locator('#historyThreads [data-thread="qa-thread"]').click();await p1.locator('#historyConfirmed').check();await p1.locator('#historyResume').click();await wait(p1,()=>window.hccHandoff.canControl&&window.hccHandoff.session?.type==='app-server');
126
+ await p1.locator('#codexDraft').fill('test interactive permissions');await p1.locator('#codexSend').click();await p1.locator('#codexApprovals [data-permission="network"]').waitFor();await p1.locator('#codexApprovals [data-permission="network"]').check();await shot(p1,'codex-permissions-desktop');await p1.locator('#codexApprovals button[data-decision="accept"]').click();
127
+ await p1.locator('#codexApprovals [data-question="0"]').waitFor();await p1.locator('#codexApprovals [data-question="0"]').selectOption('0');await shot(p1,'codex-question-desktop');await p1.locator('#codexApprovals button[data-decision="accept"]').click();check('Web-owned App Server permissions and userInput use the same form and actual RPC replies');
128
+ await p1.locator('#codexApprovals [data-mcp-field="0"]').waitFor();await p1.locator('#codexApprovals [data-mcp-field="0"]').fill('web-owned-form');await p1.locator('#codexApprovals [data-mcp-field="1"]').fill('3');await p1.locator('#codexApprovals [data-mcp-field="2"]').selectOption('1');await p1.locator('#codexApprovals [data-mcp-field="3"]').selectOption('1');await p1.locator('#codexApprovals [data-mcp-field="4"][data-mcp-option="0"]').check();await shot(p1,'web-owned-mcp-form-desktop');await p1.locator('#codexApprovals button[data-decision="accept"]').click();
129
+ await p1.waitForFunction(()=>document.querySelector('#codexApprovals button[data-decision="cancel"]')&&document.querySelector('#codexApprovals button[data-decision="accept"]')?.disabled);await p1.locator('#codexApprovals button[data-decision="cancel"]').click();await wait(p1,()=>!document.querySelector('#codexApprovals button'));
130
+ const replies=fs.readFileSync(env.HCC_TEST_MCP_RPC,'utf8').trim().split('\n').map(JSON.parse);assert.deepEqual(replies.find(reply=>reply.id===0).result,{action:'accept',content:{name:'web-owned-form',count:3,enabled:false,region:'us',features:['logs']}});assert.deepEqual(replies.find(reply=>reply.id==='mcp-url').result,{action:'cancel'});check('Web-owned MCP forms return typed content to exact RPC id zero and URL mode stays cancellable');
131
+ assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('web-owned-form')),false);check('Web-owned MCP response is absent from browser storage');
132
+ assert.deepEqual(evidence.pageErrors,[]);assert.deepEqual(evidence.consoleErrors,[]);check('zero page errors or console warnings/errors');evidence.success=true;
133
+ }catch(error){evidence.success=false;evidence.error=error.message;console.error(error);if(browser)for(const context of browser.contexts())for(const page of context.pages())try{await shot(page,'failure-'+browser.contexts().indexOf(context));}catch{}}
134
+ finally{evidence.finishedAt=new Date().toISOString();const after=sourceHashes();evidence.sourceChangesDuringValidation=[...new Set([...Object.keys(evidence.sourceFiles),...Object.keys(after)])].filter(file=>after[file]!==evidence.sourceFiles[file]);if(evidence.sourceChangesDuringValidation.length){evidence.success=false;evidence.error='Source changed during browser validation';}await browser?.close();if(runtime)try{hcc('down');}catch(e){evidence.cleanupError=e.message;}await service?.shutdown();spawnSync(path.join(bin,'tmux'),['kill-server'],{env,stdio:'ignore'});fs.writeFileSync(path.join(dir,'evidence.json'),JSON.stringify(evidence,null,2));fs.writeFileSync('/tmp/hcc-mcp-form-ui-latest.json',JSON.stringify(evidence,null,2));for(const folder of [root,home,bin])fs.rmSync(folder,{recursive:true,force:true});console.log('EVIDENCE '+dir);if(!evidence.success)process.exitCode=1;}