@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +111 -23
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/dsh.md +2 -2
  7. package/docs/dsh.zh-CN.md +3 -3
  8. package/docs/native.md +84 -10
  9. package/docs/native.zh-CN.md +66 -5
  10. package/docs/web-handoff.zh-CN.md +11 -3
  11. package/lib/cli/app.mjs +42 -1
  12. package/lib/cli/commands/coordination.mjs +7 -2
  13. package/lib/cli/commands/dsh.mjs +1 -1
  14. package/lib/cli/commands/gc.mjs +9 -4
  15. package/lib/cli/commands/hook.mjs +6 -1
  16. package/lib/cli/commands/install.mjs +48 -6
  17. package/lib/cli/commands/lifecycle.mjs +14 -3
  18. package/lib/cli/commands/native.mjs +16 -9
  19. package/lib/cli/commands/query.mjs +15 -10
  20. package/lib/cli/commands/run.mjs +7 -6
  21. package/lib/core/coordination/results.mjs +27 -10
  22. package/lib/db/connection.mjs +135 -10
  23. package/lib/db/schema.mjs +59 -0
  24. package/lib/discover.mjs +4 -4
  25. package/lib/guidance.mjs +3 -2
  26. package/lib/integrations/codex-interactions.mjs +13 -11
  27. package/lib/integrations/dsh-collaboration.mjs +6 -12
  28. package/lib/integrations/dsh-cordis.mjs +17 -13
  29. package/lib/integrations/dsh.mjs +12 -9
  30. package/lib/integrations/hooks.mjs +132 -40
  31. package/lib/integrations/mcp-elicitation.mjs +116 -0
  32. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  33. package/lib/integrations/native/interactions.mjs +2 -1
  34. package/lib/integrations/peers/identity.mjs +42 -7
  35. package/lib/process/identity.mjs +15 -14
  36. package/lib/runtime/buffer-evidence.mjs +2 -1
  37. package/lib/runtime/native/service.mjs +11 -5
  38. package/lib/runtime/native/store.mjs +70 -18
  39. package/lib/runtime/paths.mjs +17 -2
  40. package/lib/runtime/private-file.mjs +76 -0
  41. package/lib/runtime/private-state.mjs +546 -0
  42. package/lib/runtime/project-context.mjs +2 -1
  43. package/lib/runtime/project-path.mjs +148 -12
  44. package/lib/runtime/project-trust.mjs +79 -0
  45. package/lib/runtime/projects.mjs +6 -3
  46. package/lib/runtime/state-migration.mjs +697 -0
  47. package/lib/runtime/state.mjs +22 -11
  48. package/lib/shared/automatic-result-key.mjs +6 -0
  49. package/lib/shared/json-file.mjs +55 -6
  50. package/lib/ui/help.mjs +6 -4
  51. package/lib/web/codex-app-server.mjs +26 -5
  52. package/lib/web/codex-sessions.mjs +5 -3
  53. package/lib/web/cookie-auth.mjs +1 -1
  54. package/lib/web/external-sessions.mjs +54 -5
  55. package/lib/web/http-routes.mjs +13 -9
  56. package/lib/web/http.mjs +20 -2
  57. package/lib/web/native-sessions.mjs +14 -7
  58. package/lib/web/peer-actions.mjs +27 -27
  59. package/lib/web/project-contexts.mjs +2 -3
  60. package/lib/web/runtime-main.mjs +22 -5
  61. package/lib/web/runtime.mjs +18 -4
  62. package/lib/web/session-serialize.mjs +1 -1
  63. package/lib/web/startup.mjs +115 -22
  64. package/lib/web/task-results.mjs +20 -17
  65. package/lib/web/terminal-assets.mjs +18 -0
  66. package/lib/web/tls.mjs +84 -33
  67. package/lib/web/tmux-sessions.mjs +2 -2
  68. package/lib/web/tmux-stream.mjs +3 -4
  69. package/lib/web/ui-codex.mjs +161 -32
  70. package/lib/web/ui-interactions.mjs +137 -4
  71. package/lib/web/ui-native.mjs +31 -13
  72. package/lib/web/ui-template.mjs +10 -3
  73. package/package.json +1 -1
  74. package/scripts/mcp-form-live-fixture.mjs +81 -0
  75. package/scripts/native-installed-acceptance.mjs +413 -0
  76. package/scripts/regression.mjs +46 -29
  77. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  78. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  79. package/scripts/web-native-interaction-acceptance.mjs +77 -10
@@ -41,6 +41,7 @@ const managedTmuxSessions = new Set();
41
41
 
42
42
  const env = {
43
43
  ...process.env,
44
+ NODE_NO_WARNINGS: '1',
44
45
  HOME: home,
45
46
  PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
46
47
  SHELL: '/bin/bash'
@@ -2449,7 +2450,7 @@ async function assertShimIgnoresGlobalRuntime(generateShim) {
2449
2450
  started_at: Math.floor(Date.now() / 1000),
2450
2451
  global_runtime: true
2451
2452
  };
2452
- fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2));
2453
+ fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2), { mode: 0o600 });
2453
2454
 
2454
2455
  const hccWrapper = path.join(testDir, 'hcc-wrapper');
2455
2456
  fs.writeFileSync(hccWrapper, `#!/usr/bin/env bash\nexec ${sh(process.execPath)} ${sh(hccBin)} "$@"\n`, { mode: 0o755 });
@@ -2553,13 +2554,17 @@ function startRuntime(options = {}) {
2553
2554
  async function cookieSessionExpiryWorkflow() {
2554
2555
  const expirySessionId = `cookie-expiry-${testId}`;
2555
2556
  const marker = `EXPIRED_COOKIE_INPUT_${testId}`;
2557
+ // Sessions use whole-second timestamps. A one-second TTL can expire during
2558
+ // the initial WebSocket/PTY snapshot, before this test reaches its assertion.
2559
+ // Keep a bounded setup window, then explicitly wait past the same TTL.
2560
+ const cookieTtlSec = 10;
2556
2561
  let ws = null;
2557
2562
 
2558
2563
  startRuntime({
2559
2564
  env: {
2560
2565
  ...env,
2561
2566
  HCC_REGRESSION_TEST: '1',
2562
- HCC_REGRESSION_WEB_SESSION_TTL_SEC: '1'
2567
+ HCC_REGRESSION_WEB_SESSION_TTL_SEC: String(cookieTtlSec)
2563
2568
  }
2564
2569
  });
2565
2570
  try {
@@ -2588,7 +2593,7 @@ async function cookieSessionExpiryWorkflow() {
2588
2593
  ws = await openCookieTerminalWebSocket(expirySessionId, auth.sid, { root });
2589
2594
  if (!ws.hccActionToken) fail('short-TTL cookie terminal snapshot omitted its action token');
2590
2595
  const closed = new Promise((resolve, reject) => {
2591
- const timer = setTimeout(() => reject(new Error('expired cookie websocket close timeout')), 5000);
2596
+ const timer = setTimeout(() => reject(new Error('expired cookie websocket close timeout')), (cookieTtlSec + 5) * 1000);
2592
2597
  ws.once('close', (code, reason) => {
2593
2598
  clearTimeout(timer);
2594
2599
  resolve({ code, reason: String(reason || '') });
@@ -2599,7 +2604,7 @@ async function cookieSessionExpiryWorkflow() {
2599
2604
  });
2600
2605
  });
2601
2606
 
2602
- await sleep(1200);
2607
+ await sleep(cookieTtlSec * 1000 + 200);
2603
2608
  ws.send(JSON.stringify({
2604
2609
  type: 'input',
2605
2610
  data: `echo ${marker}\r`,
@@ -2808,6 +2813,10 @@ function cleanup() {
2808
2813
  if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
2809
2814
  runMaybe('tmux', ['kill-server']);
2810
2815
  }
2816
+ if (process.env.HCC_REGRESSION_PRESERVE_ARTIFACTS === '1') {
2817
+ log(`regression artifacts preserved: ${root}`);
2818
+ return;
2819
+ }
2811
2820
  for (const dir of [root, home, fakeBin, outDir]) {
2812
2821
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
2813
2822
  }
@@ -4420,7 +4429,14 @@ async function multiProjectWebWorkflow() {
4420
4429
 
4421
4430
  const htmlResponse = await fetch(currentRuntimeUrl('/'));
4422
4431
  const html = await htmlResponse.text();
4423
- for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory', 'commandbar', 'lineInput', 'Send text to active terminal']) {
4432
+ const startFormHtml = html.match(/<form\b[^>]*\bid="startForm"[^>]*>[\s\S]*?<\/form>/)?.[0];
4433
+ if (!startFormHtml) fail('web form missing simplified session form');
4434
+ // A current-directory label in an approval or session details is valid;
4435
+ // obsolete creation inputs must be checked inside the creation form.
4436
+ for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory']) {
4437
+ if (startFormHtml.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
4438
+ }
4439
+ for (const forbidden of ['commandbar', 'lineInput', 'Send text to active terminal']) {
4424
4440
  if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
4425
4441
  }
4426
4442
  for (const expected of [
@@ -5538,7 +5554,7 @@ async function bufferGcArbitrationWorkflow() {
5538
5554
 
5539
5555
  const liveId = `gc-live-external-${testId}`;
5540
5556
  const liveFiles = ['out', 'in', 'resize', 'meta'].map((suffix) => path.join(rootBufs, `${liveId}.${suffix}`));
5541
- for (const file of liveFiles.slice(0, 3)) fs.writeFileSync(file, file.endsWith('.out') ? 'live\n' : '');
5557
+ for (const file of liveFiles.slice(0, 3)) fs.writeFileSync(file, file.endsWith('.out') ? 'live\n' : '', { mode: 0o600 });
5542
5558
  const identity = inspectProcessIdentity(process.pid).identity;
5543
5559
  fs.writeFileSync(liveFiles[3], JSON.stringify({
5544
5560
  id: liveId,
@@ -5552,7 +5568,7 @@ async function bufferGcArbitrationWorkflow() {
5552
5568
  wrapper_identity: identity,
5553
5569
  cols: 120,
5554
5570
  rows: 40
5555
- }));
5571
+ }), { mode: 0o600 });
5556
5572
  await waitFor(async () => {
5557
5573
  const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
5558
5574
  return (data.sessions || []).some((session) => session.id === liveId);
@@ -5561,8 +5577,8 @@ async function bufferGcArbitrationWorkflow() {
5561
5577
 
5562
5578
  const legacyId = `gc-legacy-${testId}`;
5563
5579
  const legacyFiles = ['out', 'in', 'resize', 'meta'].map((suffix) => path.join(rootBufs, `${legacyId}.${suffix}`));
5564
- for (const file of legacyFiles.slice(0, 3)) fs.writeFileSync(file, '');
5565
- fs.writeFileSync(legacyFiles[3], JSON.stringify({ id: legacyId, pid: process.pid, wrapper_pid: process.pid }));
5580
+ for (const file of legacyFiles.slice(0, 3)) fs.writeFileSync(file, '', { mode: 0o600 });
5581
+ fs.writeFileSync(legacyFiles[3], JSON.stringify({ id: legacyId, pid: process.pid, wrapper_pid: process.pid }), { mode: 0o600 });
5566
5582
  for (const file of legacyFiles) fs.utimesSync(file, oldTime, oldTime);
5567
5583
  await waitFor(async () => {
5568
5584
  const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
@@ -5572,7 +5588,7 @@ async function bufferGcArbitrationWorkflow() {
5572
5588
  const siblingLiveId = `gc-sibling-external-${testId}`;
5573
5589
  const siblingLiveFiles = ['out', 'in', 'resize', 'meta']
5574
5590
  .map((suffix) => path.join(siblingBufs, `${siblingLiveId}.${suffix}`));
5575
- for (const file of siblingLiveFiles.slice(0, 3)) fs.writeFileSync(file, '');
5591
+ for (const file of siblingLiveFiles.slice(0, 3)) fs.writeFileSync(file, '', { mode: 0o600 });
5576
5592
  fs.writeFileSync(siblingLiveFiles[3], JSON.stringify({
5577
5593
  id: siblingLiveId,
5578
5594
  kind: 'shell',
@@ -5585,7 +5601,7 @@ async function bufferGcArbitrationWorkflow() {
5585
5601
  wrapper_identity: identity,
5586
5602
  cols: 120,
5587
5603
  rows: 40
5588
- }));
5604
+ }), { mode: 0o600 });
5589
5605
  await waitFor(async () => {
5590
5606
  const data = await (await runtimeFetch('/api/sessions', {}, { root: secondProjectRoot })).json();
5591
5607
  return (data.sessions || []).some((session) => session.id === siblingLiveId);
@@ -5884,7 +5900,7 @@ async function bufferGcArbitrationWorkflow() {
5884
5900
  base_url: server?.baseUrl || 'http://127.0.0.1:1',
5885
5901
  token: 'regression-token'
5886
5902
  };
5887
- fs.writeFileSync(path.join(isolatedRoot, '.hello-cc', 'runtime.json'), JSON.stringify(pointer));
5903
+ fs.writeFileSync(path.join(isolatedRoot, '.hello-cc', 'runtime.json'), JSON.stringify(pointer), { mode: 0o600 });
5888
5904
  const gc = run(process.execPath, [hccBin, '--root', isolatedRoot, '--json', 'gc', '--older-than', '0', '--yes'], { env: isolatedEnv });
5889
5905
  const payload = JSON.parse(gc);
5890
5906
  if (!fs.existsSync(orphan) || Number(payload.data?.deferred_buf_files || 0) < 1) {
@@ -7545,7 +7561,7 @@ async function syntaxAndHelp() {
7545
7561
  fail('runtime state clearRuntime did not remove the reused-pid global pointer');
7546
7562
  }
7547
7563
 
7548
- fs.writeFileSync(globalRuntimeFile, '{bad');
7564
+ fs.writeFileSync(globalRuntimeFile, '{bad', { mode: 0o600 });
7549
7565
  if (runtimeState.readGlobalRuntimeFile() !== null) {
7550
7566
  fail('runtime state readGlobalRuntimeFile did not reject invalid JSON');
7551
7567
  }
@@ -9493,7 +9509,7 @@ async function syntaxAndHelp() {
9493
9509
  process.env.HOME = tlsHome;
9494
9510
  const webTls = await import(path.join(repoRoot, 'lib', 'web', 'tls.mjs'));
9495
9511
  tlsCredentials = webTls.ensureSelfSignedCert();
9496
- const tlsDir = path.join(tlsHome, '.hello-cc', 'tls');
9512
+ const tlsDir = path.join(fs.realpathSync(tlsHome), '.hello-cc', 'tls');
9497
9513
  const currentPointerPath = path.join(tlsDir, 'current.json');
9498
9514
  const currentGeneration = JSON.parse(fs.readFileSync(currentPointerPath, 'utf8')).generation;
9499
9515
  const generationNames = {
@@ -9529,27 +9545,28 @@ async function syntaxAndHelp() {
9529
9545
  }
9530
9546
  const originalTlsCert = tlsCredentials.cert;
9531
9547
  const originalTlsCertPath = tlsCredentials.certPath;
9532
- const originalReadFileSync = fs.readFileSync;
9533
- let pointerReads = 0;
9548
+ const originalLstatSync = fs.lstatSync;
9549
+ let pointerChecks = 0;
9534
9550
  let pointerSwitchedBeforeDelete = false;
9535
- fs.readFileSync = (file, ...args) => {
9551
+ fs.lstatSync = (file, ...args) => {
9536
9552
  if (path.resolve(String(file)) === path.resolve(currentPointerPath)) {
9537
- pointerReads += 1;
9538
- // Read 1 validates the old current; read 2 starts cleanup; read 3 is
9539
- // the deletion-time TOCTOU guard for the stale switched-current row.
9540
- if (pointerReads === 3) {
9553
+ pointerChecks += 1;
9554
+ // The private reader lstat-checks before and after each descriptor
9555
+ // read. Checks 1-2 validate the old current; 3-4 start cleanup; check
9556
+ // 5 is the deletion-time guard for the stale switched-current row.
9557
+ if (pointerChecks === 5) {
9541
9558
  const nextPointerPath = path.join(tlsDir, `.current-regression-${testId}.tmp`);
9542
9559
  fs.writeFileSync(nextPointerPath, `${JSON.stringify({ generation: generationNames.switchedCurrent })}\n`, { mode: 0o600 });
9543
9560
  fs.renameSync(nextPointerPath, currentPointerPath);
9544
9561
  pointerSwitchedBeforeDelete = true;
9545
9562
  }
9546
9563
  }
9547
- return originalReadFileSync(file, ...args);
9564
+ return originalLstatSync(file, ...args);
9548
9565
  };
9549
9566
  try {
9550
9567
  tlsCredentials = webTls.ensureSelfSignedCert();
9551
9568
  } finally {
9552
- fs.readFileSync = originalReadFileSync;
9569
+ fs.lstatSync = originalLstatSync;
9553
9570
  }
9554
9571
  const publishedGeneration = JSON.parse(fs.readFileSync(currentPointerPath, 'utf8')).generation;
9555
9572
  const remainingGenerations = fs.readdirSync(tlsDir, { withFileTypes: true })
@@ -9565,14 +9582,14 @@ async function syntaxAndHelp() {
9565
9582
  ].sort();
9566
9583
  if (tlsCredentials.cert !== originalTlsCert ||
9567
9584
  tlsCredentials.certPath !== originalTlsCertPath ||
9568
- !pointerSwitchedBeforeDelete || pointerReads < 4 ||
9585
+ !pointerSwitchedBeforeDelete || pointerChecks < 6 ||
9569
9586
  publishedGeneration !== generationNames.switchedCurrent ||
9570
9587
  !fs.existsSync(path.join(tlsDir, generationNames.initialCurrent, '.published')) ||
9571
9588
  !fs.existsSync(path.join(tlsDir, generationNames.switchedCurrent, '.published')) ||
9572
9589
  !fs.existsSync(path.join(tlsDir, generationNames.activeCreating, '.creating')) ||
9573
9590
  fs.existsSync(path.join(tlsDir, generationNames.deadCreating)) ||
9574
9591
  JSON.stringify(remainingGenerations) !== JSON.stringify(expectedRemainingGenerations)) {
9575
- fail(`TLS generation cleanup violated current/previous/candidate lifecycle protection:\n${JSON.stringify({ generationNames, pointerReads, pointerSwitchedBeforeDelete, publishedGeneration, remainingGenerations }, null, 2)}`);
9592
+ fail(`TLS generation cleanup violated current/previous/candidate lifecycle protection:\n${JSON.stringify({ generationNames, pointerChecks, pointerSwitchedBeforeDelete, publishedGeneration, remainingGenerations }, null, 2)}`);
9576
9593
  }
9577
9594
  } finally {
9578
9595
  if (savedTlsHome === undefined) delete process.env.HOME;
@@ -10133,9 +10150,9 @@ async function processEvidenceWorkflow() {
10133
10150
  const legacyProcess = spawn('sleep', ['30'], { stdio: 'ignore' });
10134
10151
  const legacyFiles = ['out', 'in', 'resize', 'meta']
10135
10152
  .map((suffix) => path.join(externalDir, `${legacyExternalId}.${suffix}`));
10136
- fs.writeFileSync(legacyFiles[0], 'legacy external\n');
10137
- fs.writeFileSync(legacyFiles[1], '');
10138
- fs.writeFileSync(legacyFiles[2], '');
10153
+ fs.writeFileSync(legacyFiles[0], 'legacy external\n', { mode: 0o600 });
10154
+ fs.writeFileSync(legacyFiles[1], '', { mode: 0o600 });
10155
+ fs.writeFileSync(legacyFiles[2], '', { mode: 0o600 });
10139
10156
  fs.writeFileSync(legacyFiles[3], JSON.stringify({
10140
10157
  id: legacyExternalId,
10141
10158
  kind: 'shell',
@@ -10146,7 +10163,7 @@ async function processEvidenceWorkflow() {
10146
10163
  wrapper_pid: legacyProcess.pid,
10147
10164
  cols: 120,
10148
10165
  rows: 40
10149
- }));
10166
+ }), { mode: 0o600 });
10150
10167
  await waitFor(async () => {
10151
10168
  const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
10152
10169
  return (data.sessions || []).some((session) => session.id === legacyExternalId);
@@ -0,0 +1,119 @@
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:'Isolated installed Chrome + Playwright; browser-extension connectivity is not exercised',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)=>{
55
+ const file=path.join(dir,name+'.png');
56
+ // Viewport changes can leave an old compositor frame after layout settles.
57
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
58
+ await page.screenshot({path:file});evidence.screenshots.push(file);
59
+ };
60
+ const wait=(page,fn)=>page.waitForFunction(fn,null,{timeout:12000});
61
+ 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)});}
62
+ try{
63
+ hcc('up','--no-discover','--no-guidance');
64
+ service=await startNativeService(ctx,deps,{pollMs:60000,adapterFactory:async(provider,options)=>{
65
+ 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;
66
+ interactions=createNativeInteractions({executorId:options.executorId,isActive:(session,turn)=>session===state.sessionId&&turn===state.turnId&&state.status==='running',onChange:emit});
67
+ 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;
68
+ }});
69
+ await api('POST','/workers',{peer:'native-qa',provider:'codex'});
70
+ hcc('web','--local','--port',String(port),'--no-discover','--no-guidance');runtime=true;
71
+ browser=await chromium.launch({headless:true,executablePath:process.env.HCC_ACCEPTANCE_CHROME||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
72
+ const a=await browser.newContext({viewport:{width:1440,height:1000}}),b=await browser.newContext({viewport:{width:1280,height:900}});
73
+ for(const c of [a,b])await c.addInitScript(()=>localStorage.setItem('hcc.lang','en'));
74
+ const p1=await a.newPage(),p2=await b.newPage();
75
+ 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());}
76
+ await p1.goto(base+'/?token='+env.HCC_WEB_TOKEN);await wait(p1,()=>window.hccHandoff?.canControl&&window.hccHandoff.session?.type==='native');
77
+ 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');
78
+ const command=ask('command','item/commandExecution/requestApproval',{command:'echo <script>bad()</script>',cwd:root});
79
+ await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals button[data-decision="accept"]').waitFor();
80
+ await p2.goto(base+'/?token='+env.HCC_WEB_TOKEN);await wait(p2,()=>window.hccHandoff?.session?.type==='native'&&window.hccHandoff.actionToken);
81
+ 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');
82
+ 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');
83
+ const permissions=ask('permissions','item/permissions/requestApproval',{permissions:{network:{enabled:true},fileSystem:{read:['/read-only'],write:['/write-only']}}});
84
+ await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals [data-permission="read"]').check();
85
+ await shot(p1,'native-permissions-desktop');
86
+ 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');
87
+ 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}]});
88
+ 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');
89
+ 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');
90
+ 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');
91
+ 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']}}});
92
+ 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');
93
+ const form=ask('mcp-form','mcpServer/elicitation/request',formParams);
94
+ await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals [data-mcp-field="0"]').waitFor();
95
+ assert.equal(await p1.locator('#nativeApprovals script').count(),0);assert.ok((await p1.locator('#nativeApprovals').innerText()).includes('Project <name>'));
96
+ await p1.locator('#nativeApprovals [data-mcp-field="0"]').fill('private-ui-form');await p1.locator('#nativeApprovals [data-mcp-field="1"]').fill('0');
97
+ 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();
98
+ 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');
99
+ 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');
100
+ 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');
101
+ 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']}});
102
+ 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});
103
+ 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');
104
+ 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');
105
+ 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);
106
+ 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');
107
+ const mobile=ask('mobile','item/permissions/requestApproval',{permissions:{network:{enabled:true},fileSystem:{read:['/long/path/with-a-readable-file-name'],write:['/work']}}});
108
+ await p2.setViewportSize({width:390,height:844});await p2.locator('#nativeRead').click();await p2.locator('#nativeApprovals [data-permission="read"]').waitFor();
109
+ 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');
110
+ 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');
111
+ 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();
112
+ 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');
113
+ 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();
114
+ 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'));
115
+ 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');
116
+ assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('web-owned-form')),false);check('Web-owned MCP response is absent from browser storage');
117
+ assert.deepEqual(evidence.pageErrors,[]);assert.deepEqual(evidence.consoleErrors,[]);check('zero page errors or console warnings/errors');evidence.success=true;
118
+ }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{}}
119
+ 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;}