@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
@@ -19,28 +19,46 @@ import { inspectProcessIdentity } from '../lib/process/identity.mjs';
19
19
  import { createNativeAdapter } from '../lib/integrations/native/index.mjs';
20
20
  import { startNativeService } from '../lib/runtime/native/service.mjs';
21
21
  import { nativeRequest } from '../lib/runtime/native/client.mjs';
22
+ import { readNativePointer } from '../lib/runtime/native/store.mjs';
23
+ import { redactSecrets } from '../lib/shared/redact.mjs';
24
+ import { resolveDshBinary, createDshEnvironment } from '../lib/integrations/dsh.mjs';
25
+ import { checkDshRuntime, DSH_CORDIS_VERSION } from '../lib/integrations/dsh-cordis.mjs';
26
+ import { interactionCompletionDiagnostics, isKnownDshEscalationApproval } from './web-native-interaction-diagnostics.mjs';
22
27
 
23
28
  const args = process.argv.slice(2);
24
29
  const option = (name, fallback) => args.includes(name) ? args[args.indexOf(name) + 1] : fallback;
25
30
  if (!args.includes('--run-live')) {
26
- console.log('Usage: node scripts/web-native-interaction-acceptance.mjs --run-live [--provider codex|claude|all] [--codex-bin PATH] [--claude-package DIR] [--output FILE]');
31
+ console.log('Usage: node scripts/web-native-interaction-acceptance.mjs --run-live [--provider codex|claude|dsh|all] [--codex-bin PATH] [--claude-package DIR] [--dsh-bin PATH] [--output FILE]');
27
32
  console.log('Set HCC_ACCEPTANCE_PLAYWRIGHT and optionally HCC_ACCEPTANCE_CHROME. Claude uses default SDK resolution; --claude-package links an installed package into the isolated worker project.');
33
+ console.log('dsh uses an installed public launcher and the existing DEEPSEEK_API_KEY. A private ACP profile requests approval only for the two acceptance-owned target files.');
28
34
  process.exit(0);
29
35
  }
30
- const providers = option('--provider', 'codex') === 'all' ? ['codex', 'claude'] : [option('--provider', 'codex')];
31
- assert.ok(providers.every(value => ['codex', 'claude'].includes(value)), 'unknown provider');
36
+ const providers = option('--provider', 'codex') === 'all' ? ['codex', 'claude', 'dsh'] : [option('--provider', 'codex')];
37
+ assert.ok(providers.every(value => ['codex', 'claude', 'dsh'].includes(value)), 'unknown provider');
32
38
  const repo = fileURLToPath(new URL('..', import.meta.url));
33
39
  const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-live-web-interactions-')));
34
40
  fs.chmodSync(directory, 0o700);
35
- const root = path.join(directory, 'project'), bin = path.join(directory, 'bin');
41
+ const root = path.join(directory, 'project'), bin = path.join(directory, 'bin'), taskHome = path.join(directory, 'home');
36
42
  const codexHome = path.join(directory, 'codex-home'), claudeHome = path.join(directory, 'claude-home');
43
+ const dshHome = path.join(directory, 'dsh-home');
37
44
  const outside = path.join(directory, 'requested-write');
38
- for (const folder of [root, bin, codexHome, claudeHome, outside, path.join(root, '.hello-cc')]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
45
+ const dshAllowed = path.join(outside, 'dsh-allowed.txt'), dshDenied = path.join(directory, 'dsh-denied', 'must-not-exist.txt');
46
+ let dshBinary;
47
+ for (const folder of [root, bin, taskHome, codexHome, claudeHome, dshHome, outside, path.join(root, '.hello-cc')]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
39
48
  const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
40
49
  const originalHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
41
50
  const protectedFiles = ['config.toml', 'auth.json'].map(name => path.join(originalHome, name)).filter(file => fs.existsSync(file));
42
51
  const protectedHashes = protectedFiles.map(file => [file, digest(file)]);
43
- const sourceHashes = () => Object.fromEntries(['lib/integrations/codex-interactions.mjs', 'lib/integrations/native/codex.mjs', 'lib/integrations/native/claude.mjs', 'lib/integrations/native/interactions.mjs', 'lib/runtime/native/service.mjs', 'lib/web/native-sessions.mjs', 'lib/web/ui-native.mjs', 'lib/web/ui-interactions.mjs', 'scripts/web-native-interaction-acceptance.mjs'].map(name => [name, digest(path.join(repo, name))]));
52
+ function sourceHashes() {
53
+ const result = {};
54
+ function walk(name) {
55
+ const file = path.join(repo, name);
56
+ if (fs.statSync(file).isDirectory()) for (const child of fs.readdirSync(file).sort()) walk(path.join(name, child));
57
+ else result[name] = digest(file);
58
+ }
59
+ for (const name of ['bin', 'lib', 'package.json', 'package-lock.json', 'scripts/web-native-interaction-acceptance.mjs', 'scripts/web-native-interaction-diagnostics.mjs']) walk(name);
60
+ return result;
61
+ }
44
62
  const evidence = { startedAt: new Date().toISOString(), directory, sourceFiles: sourceHashes(), providers: {}, checks: [], screenshots: [], pageErrors: [], consoleErrors: [], completed: false,
45
63
  browser: 'Browser plugin not available; isolated installed Chrome and Playwright', modelInferenceCalled: false };
46
64
  const output = path.resolve(option('--output', path.join(directory, 'evidence.json')));
@@ -48,7 +66,7 @@ const tmux = process.env.HCC_ACCEPTANCE_TMUX || '/opt/homebrew/bin/tmux';
48
66
  const socket = 'hcc-interactions-' + randomUUID();
49
67
  const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
50
68
  fs.writeFileSync(path.join(bin, 'tmux'), '#!/bin/sh\nexec ' + quote(tmux) + ' -L ' + quote(socket) + ' "$@"\n', { mode: 0o700 });
51
- const env = { ...process.env, PATH: bin + ':' + path.dirname(process.execPath) + ':' + process.env.PATH, CODEX_HOME: codexHome,
69
+ const env = { ...process.env, PATH: bin + ':' + path.dirname(process.execPath) + ':' + process.env.PATH, HOME: taskHome, CODEX_HOME: codexHome,
52
70
  HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1', HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID() };
53
71
  for (const key of Object.keys(env)) if (key.startsWith('HCC_') && !['HCC_WEB_TOKEN', 'HCC_NO_AUTO_INSTALL_TMUX', 'HCC_SHIM_ENSURED', 'HCC_SHIM_NO_ATTACH'].includes(key)) delete env[key];
54
72
  const ctx = { root, dbPath: path.join(root, '.hello-cc', 'mesh.db') };
@@ -71,6 +89,7 @@ async function until(predicate, label, timeout = 180000) {
71
89
  throw new Error('Timed out: ' + label);
72
90
  }
73
91
  let service, browser, runtimeStarted = false;
92
+ const answeredApprovals = new Map();
74
93
  try {
75
94
  assert.ok(fs.existsSync(tmux), 'tmux must be installed');
76
95
  assert.ok(process.env.HCC_ACCEPTANCE_PLAYWRIGHT, 'Set HCC_ACCEPTANCE_PLAYWRIGHT');
@@ -80,7 +99,21 @@ try {
80
99
  const header = line.match(/^\s*\[\[?([^\]]+)\]\]?/); if (header) section = header[1].split('.')[0].replaceAll('"', '').replaceAll("'", '');
81
100
  return section ? section === 'model_providers' : /^\s*(?:model(?:_provider|_reasoning_effort|_reasoning_summary|_verbosity|_catalog_json)?|cli_auth_credentials_store)\s*=/.test(line);
82
101
  }).join('\n');
83
- fs.writeFileSync(path.join(codexHome, 'config.toml'), config + '\n', { mode: 0o600 });
102
+ // The provider still receives an isolated HOME. Only its existing
103
+ // credential helper reads HOME-bound caller authentication state.
104
+ const helperDir = path.join(directory, 'credential-commands'); fs.mkdirSync(helperDir, { mode: 0o700 });
105
+ let authSection = '', wrapped = 0;
106
+ const isolatedConfig = config.split('\n').map(line => {
107
+ const heading = line.match(/^\s*\[\[?([^\]]+)\]\]?\s*(?:#.*)?$/); if (heading) authSection = heading[1];
108
+ const command = authSection.endsWith('.auth') && line.match(/^\s*command\s*=\s*((?:"(?:[^"\\]|\\.)*"|'[^']*'))\s*(?:#.*)?$/);
109
+ if (!command) return line;
110
+ const original = command[1].startsWith("'") ? command[1].slice(1, -1) : JSON.parse(command[1]);
111
+ const wrapper = path.join(helperDir, 'command-' + ++wrapped);
112
+ fs.writeFileSync(wrapper, '#!/bin/sh\nexport HOME=' + quote(os.homedir()) + '\nexec ' + quote(original) + ' "$@"\n', { mode: 0o700 });
113
+ return 'command = ' + JSON.stringify(wrapper);
114
+ }).join('\n');
115
+ evidence.authenticationHelperHomePreserved = wrapped > 0;
116
+ fs.writeFileSync(path.join(codexHome, 'config.toml'), isolatedConfig + '\n', { mode: 0o600 });
84
117
  if (fs.existsSync(path.join(originalHome, 'auth.json'))) { fs.copyFileSync(path.join(originalHome, 'auth.json'), path.join(codexHome, 'auth.json')); fs.chmodSync(path.join(codexHome, 'auth.json'), 0o600); }
85
118
  evidence.providers.codex = { version: spawnSync(option('--codex-bin', '/opt/homebrew/bin/codex'), ['--version'], { env, encoding: 'utf8', timeout: 5000 }).stdout.trim(), featureConfiguration: 'HCC per-thread interactive tool switches; no acceptance-only switches' };
86
119
  }
@@ -92,10 +125,45 @@ try {
92
125
  fs.symlinkSync(installed, path.join(scoped, 'claude-agent-sdk'), 'dir');
93
126
  evidence.providers.claude = { sdkVersion: pkg.version, dependencyResolution: 'default loader: optional SDK installed in isolated worker project; no query injection' };
94
127
  }
128
+ if (providers.includes('dsh')) {
129
+ assert.ok(process.env.DEEPSEEK_API_KEY, 'dsh live acceptance requires the existing DEEPSEEK_API_KEY');
130
+ dshBinary = resolveDshBinary({ dshBin: option('--dsh-bin') });
131
+ assert.ok(dshBinary, 'dsh public launcher must be installed');
132
+ let runtimeCheck = 'Node package resolution from public launcher';
133
+ try { checkDshRuntime(dshBinary); }
134
+ catch (error) {
135
+ // The official desktop launcher stores packages in app.asar. Verify the
136
+ // same four baseline versions using its own Electron Node/asar loader.
137
+ const launcher = fs.statSync(dshBinary).size < 65536 ? fs.readFileSync(dshBinary, 'utf8') : '';
138
+ if (error.code !== 'MODULE_NOT_FOUND' || !launcher.includes('ELECTRON_RUN_AS_NODE=1') || !launcher.includes('app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js')) throw error;
139
+ const resources = path.resolve(path.dirname(dshBinary), '../../..');
140
+ const executable = path.resolve(resources, '../MacOS/DeepSeek Harness');
141
+ const anchor = path.join(resources, 'app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js');
142
+ assert.ok(fs.existsSync(executable) && fs.existsSync(path.join(resources, 'app.asar')), 'Official desktop runtime layout must exist');
143
+ const script = `const fs=require('node:fs'),path=require('node:path'),req=require('node:module').createRequire(${JSON.stringify(anchor)});const versions={};for(const name of ['dsh-agent','dsh-agent-loop','dsh-tools','dsh-session']){let dir=path.dirname(req.resolve('@deepseek-ai/'+name)),found=false;while(path.dirname(dir)!==dir){const file=path.join(dir,'package.json');if(fs.existsSync(file)){const p=JSON.parse(fs.readFileSync(file,'utf8'));if(p.name==='@deepseek-ai/'+name){if(p.version!==${JSON.stringify(DSH_CORDIS_VERSION)})throw new Error('Unsupported '+name+' version '+p.version);versions[name]=p.version;found=true;break;}}dir=path.dirname(dir);}if(!found)throw new Error('Missing runtime manifest '+name);}console.log(JSON.stringify(versions));`;
144
+ const checked = spawnSync(executable, ['--expose-internals', '-e', script], { env: { ...env, ELECTRON_RUN_AS_NODE: '1' }, encoding: 'utf8', timeout: 15000 });
145
+ assert.equal(checked.status, 0, 'Desktop runtime baseline check failed: ' + redactSecrets(checked.stderr || checked.error?.message || ''));
146
+ const versions = JSON.parse(checked.stdout.trim());
147
+ assert.equal(Object.keys(versions).length, 4); runtimeCheck = 'Official desktop Electron Node/asar loader: four component package versions verified';
148
+ }
149
+ const dshEnv = { ...createDshEnvironment(env), HOME: dshHome, DSH_HOME: path.join(dshHome, 'dsh'), DSH_TELEMETRY_DISABLED: '1' };
150
+ const initialized = spawnSync(dshBinary, ['--profile', 'acp', '--dump-config'], { cwd: root,
151
+ env: { ...dshEnv, DEEPSEEK_API_KEY: 'isolated-no-model-placeholder' }, encoding: 'utf8', timeout: 30000 });
152
+ assert.equal(initialized.status, 0, 'isolated ACP profile initialization failed (config output omitted)');
153
+ const policy = path.join(dshHome, 'acceptance-approval-policy.mjs');
154
+ // Exercise the official pre-execute policy and real model tool calls. Do
155
+ // not synthesize ACP requests or change the user's/default approval policy.
156
+ fs.writeFileSync(policy, 'export const inject=["tools"];\nexport function apply(ctx){ctx.on("tools/pre-execute",async(exec,next)=>{const args=exec.arguments||{};const targets=' + JSON.stringify([dshAllowed, dshDenied]) + ';if((["write","str_replace_editor"].includes(exec.name)&&targets.some(target=>[args.file_path,args.path].includes(target)))||(exec.name==="bash"&&targets.some(target=>String(args.command||"").includes(target))))return {kind:"ask",reason:"acceptance requires explicit approval for this test-owned write"};return next();});}\n', { mode: 0o600 });
157
+ fs.writeFileSync(path.join(dshEnv.DSH_HOME, 'profiles/acp/cordis.patch.yml'), '- insert:\n - id: acceptance-approval-policy\n name: ' + JSON.stringify(policy) + '\n', { mode: 0o600 });
158
+ evidence.providers.dsh = { runtimeVersion: DSH_CORDIS_VERSION, publicLauncher: dshBinary, runtimeCheck,
159
+ approvalPolicy: 'official tools/pre-execute ask policy in private ACP profile; only the two acceptance-owned target files',
160
+ permissionTargets: [dshAllowed, dshDenied] };
161
+ }
95
162
  hcc('up', '--no-discover', '--no-guidance');
96
163
  service = await startNativeService(ctx, deps, { adapterFactory: (provider, options) => createNativeAdapter(provider, { ...options,
97
- env: { ...options.env, ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) } }) });
98
- for (const provider of providers) await api('POST', '/workers', { peer: 'live-' + provider, provider, ...(provider === 'codex' ? { binary: option('--codex-bin', '/opt/homebrew/bin/codex') } : {}) });
164
+ env: { ...options.env, HOME: taskHome, PATH: path.dirname(process.execPath) + ':' + options.env.PATH, ...(provider === 'codex' ? { CODEX_HOME: codexHome } : provider === 'claude' ? { CLAUDE_CONFIG_DIR: claudeHome }
165
+ : { HOME: dshHome, DSH_HOME: path.join(dshHome, 'dsh'), DSH_TELEMETRY_DISABLED: '1' }) } }) });
166
+ for (const provider of providers) await api('POST', '/workers', { peer: 'live-' + provider, provider, ...(provider === 'codex' ? { binary: option('--codex-bin', '/opt/homebrew/bin/codex') } : provider === 'dsh' ? { binary: dshBinary } : {}) });
99
167
  const port = await new Promise(resolve => { const listener = net.createServer(); listener.listen(0, '127.0.0.1', () => { const port = listener.address().port; listener.close(() => resolve(port)); }); });
100
168
  hcc('web', '--local', '--port', String(port), '--no-discover', '--no-guidance'); runtimeStarted = true;
101
169
  const { chromium } = await import(process.env.HCC_ACCEPTANCE_PLAYWRIGHT);
@@ -105,10 +173,60 @@ try {
105
173
  page.on('pageerror', error => evidence.pageErrors.push(error.message));
106
174
  page.on('console', message => { if (message.type() === 'error') evidence.consoleErrors.push(message.text()); });
107
175
  await page.goto('http://127.0.0.1:' + port + '/?token=' + env.HCC_WEB_TOKEN);
176
+ await page.waitForFunction(() => Boolean(window.hccNative?.render));
177
+ await page.evaluate(() => {
178
+ const original = window.hccNative.render;
179
+ const observations = window.__hccNativeAcceptanceObservations = [];
180
+ // Observe the public WebSocket/bridge render entry without changing its
181
+ // arguments, return value or errors. Lexical HTTP read/render calls are not
182
+ // covered; failure diagnostics label their separate explicit GET below.
183
+ window.hccNative.render = function (value) {
184
+ try { return original.apply(this, arguments); }
185
+ finally {
186
+ try {
187
+ const bridge = window.hccHandoff, requests = value?.pendingApprovals || [];
188
+ observations.push({ source: 'public-render', at: Date.now(), active: bridge?.active,
189
+ connected: Boolean(value?.connected), status: value?.status || null, errorCode: value?.error?.code || null,
190
+ hasSessionId: Boolean(value?.sessionId), hasOwner: Boolean(value?.owner), hasGeneration: Boolean(value?.generation),
191
+ peerMatchesActive: value?.peer === bridge?.session?.peer_id || value?.peer === bridge?.active,
192
+ ownerMatchesExecutor: Boolean(value?.owner && value.owner === bridge?.session?.executor_id),
193
+ pendingCount: requests.length, pendingKinds: [...new Set(requests.map(request => request.kind))] });
194
+ if (observations.length > 40) observations.shift();
195
+ } catch { /* Diagnostics cannot affect the original render. */ }
196
+ }
197
+ };
198
+ });
108
199
  async function state(peer) { return api('GET', '/workers/' + peer + '/state'); }
109
- async function finish(peer, submission) {
200
+ async function finish(peer, submission, answeredRequest = null, approvedOperation = null) {
201
+ if (answeredRequest) answeredApprovals.set(peer, { request: answeredRequest, submission });
202
+ const additionalAnswered = new Set();
110
203
  return until(async () => {
111
204
  const value = await state(peer), delivery = value.deliveries.find(row => row.submission_id === submission);
205
+ const additional = answeredRequest && value.snapshot.pendingApprovals?.filter(request => request.requestId !== answeredRequest.requestId && !additionalAnswered.has(request.requestId));
206
+ if (additional?.length) {
207
+ const diagnostic = interactionCompletionDiagnostics(value, { answeredRequest, submissionId: submission,
208
+ ownedTargets: [{ role: 'allowed', path: dshAllowed }, { role: 'denied', path: dshDenied }] });
209
+ const next = additional[0];
210
+ if (additional.length === 1 && approvedOperation && isKnownDshEscalationApproval(answeredRequest, next, {
211
+ ...approvedOperation, priorDecision: 'accept', additionalAnswers: additionalAnswered.size,
212
+ })) {
213
+ // Answer the separately rendered official escalation only after the
214
+ // full original operation and ownership match. A new call, target,
215
+ // content, tool, or any third layer falls through to failure.
216
+ const key = JSON.stringify([root, peer, next.executorId, next.requestId]);
217
+ const escapedKey = key.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
218
+ const button = page.locator('#nativeApprovals button[data-decision="accept"][data-request-key="' + escapedKey + '"]');
219
+ await button.waitFor({ timeout: 15000 });
220
+ await shot('dsh-real-escalation-layer');
221
+ await button.click(); additionalAnswered.add(next.requestId);
222
+ (evidence.knownDshApprovalLayers ||= []).push({ ...diagnostic, decision: 'accept',
223
+ toolCallId: next.params.toolCall.toolCallId, requestIds: [answeredRequest.requestId, next.requestId], layerCount: 2,
224
+ sameToolCall: true, sameOriginalOperation: true, answeredThrough: 'Web button for this exact request' });
225
+ return false;
226
+ }
227
+ evidence.additionalApproval = diagnostic;
228
+ throw new Error(peer + ' requested a different pending approval after the prior answer; bounded metadata saved');
229
+ }
112
230
  if (['failed', 'uncertain'].includes(delivery?.state)) throw new Error(peer + ' delivery ended as ' + delivery.state);
113
231
  if (delivery?.state === 'completed') { evidence.modelInferenceCalled = true; return value; }
114
232
  return false;
@@ -161,7 +279,7 @@ try {
161
279
  await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await page.locator('#nativeApprovals button[data-decision="decline"]').click();
162
280
  await finish(peer, declined.submission_id); assert.equal(fs.existsSync(denied), false);
163
281
  check('Codex model respects a declined real filesystem permission');
164
- } else {
282
+ } else if (provider === 'claude') {
165
283
  const delivery = await submit(peer, 'Use the Write tool to write exactly CLAUDE_APPROVED_OK to ' + allowed + '. Await the user permission decision. Do not use Bash or any workaround. End normally after the tool result.');
166
284
  const request = await pending(peer, 'approval'); assert.equal(request.method, 'claude/canUseTool'); assert.equal(request.params.tool, 'Write');
167
285
  assert.equal(request.params.input.file_path, allowed); result.permission = { method: request.method, tool: request.params.tool, requestId: request.requestId, turnId: request.turnId };
@@ -173,10 +291,58 @@ try {
173
291
  const refusal = await pending(peer, 'approval'); assert.equal(refusal.params.tool, 'Write'); assert.equal(refusal.params.input.file_path, denied);
174
292
  await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await page.locator('#nativeApprovals button[data-decision="decline"]').click();
175
293
  await finish(peer, declined.submission_id); assert.equal(fs.existsSync(denied), false); check('Claude model respects a declined real Write operation');
294
+ } else {
295
+ const before = await state(peer); result.sessionId = before.snapshot.sessionId;
296
+ const delivery = await submit(peer, 'Use the write tool once with file_path ' + allowed + ' and content DSH_APPROVED_OK. Await the user permission decision, then finish normally. Do not use Bash, other paths or any workaround.');
297
+ const request = await pending(peer, 'approval');
298
+ assert.equal(request.method, 'session/request_permission'); assert.equal(request.sessionId, result.sessionId);
299
+ assert.equal(request.params.toolCall.rawInput.file_path, allowed); assert.equal(request.params.toolCall.rawInput.content, 'DSH_APPROVED_OK');
300
+ assert.ok(request.params.options.some(value => value.kind === 'allow_once'));
301
+ result.permission = { method: request.method, requestId: request.requestId, sessionId: request.sessionId, turnId: request.turnId,
302
+ toolCall: request.params.toolCall, approvalScope: 'allow_once' };
303
+ await page.locator('#nativeApprovals button[data-decision="accept"]').waitFor({ timeout: 15000 }); await shot('dsh-real-permission');
304
+ assert.ok((await page.locator('#nativeApprovals').innerText()).includes(allowed), 'browser must show the actual bounded operation before approval');
305
+ const details = page.locator('#nativeApprovals [data-interaction-details]');
306
+ assert.equal(await details.evaluate(element => element.open), false, 'protocol details start collapsed');
307
+ await details.locator('summary').click(); await details.evaluate(element => { element.dataset.acceptanceBeforeRefresh = '1'; });
308
+ await Promise.all([
309
+ page.waitForResponse(response => response.url().includes('/native/state?') && response.ok()),
310
+ page.locator('#nativeRead').click()
311
+ ]);
312
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
313
+ assert.equal(await details.getAttribute('data-acceptance-before-refresh'), '1', 'unchanged approval refresh reuses the same disclosure DOM');
314
+ assert.equal(await details.evaluate(element => element.open), true, 'full operation details survive state refresh');
315
+ await details.locator('summary').click();
316
+ assert.ok(await page.evaluate(() => {
317
+ const button = document.querySelector('#nativeApprovals button[data-decision="accept"]').getBoundingClientRect();
318
+ const scroll = document.querySelector('.native-scroll').getBoundingClientRect();
319
+ return button.y >= scroll.y && button.bottom <= scroll.bottom;
320
+ }), 'desktop approval is reachable without scrolling past protocol details');
321
+ await shot('dsh-real-permission-summary');
322
+ await page.setViewportSize({ width: 390, height: 844 });
323
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), 'mobile page has no horizontal overflow');
324
+ await page.locator('#nativeApprovals button[data-decision="accept"]').scrollIntoViewIfNeeded();
325
+ assert.equal(await page.locator('#nativeApprovals button[data-decision="accept"]').isEnabled(), true);
326
+ await shot('dsh-real-permission-mobile');
327
+ check('dsh operation summary and persistent details render on desktop and 390px mobile');
328
+ await page.setViewportSize({ width: 1440, height: 1000 });
329
+ await page.locator('#nativeApprovals button[data-decision="accept"]').click();
330
+ await finish(peer, delivery.submission_id, request, { target: allowed, content: 'DSH_APPROVED_OK' });
331
+ assert.equal(fs.readFileSync(allowed, 'utf8').trim(), 'DSH_APPROVED_OK');
332
+ assert.equal((await state(peer)).snapshot.sessionId, result.sessionId);
333
+ check('dsh real ACP write approval returns to the original model turn');
334
+ const declined = await submit(peer, 'Use the write tool once with file_path ' + denied + ' and content DSH_DENIED. If permission is refused, do not retry or bypass the refusal and finish normally. Do not read or modify anything else.');
335
+ const refusal = await pending(peer, 'approval'); assert.equal(refusal.method, 'session/request_permission');
336
+ assert.equal(refusal.sessionId, result.sessionId); assert.equal(refusal.params.toolCall.rawInput.file_path, denied);
337
+ result.refusal = { method: refusal.method, requestId: refusal.requestId, sessionId: refusal.sessionId, turnId: refusal.turnId, target: denied };
338
+ await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await shot('dsh-real-permission-decline');
339
+ await page.locator('#nativeApprovals button[data-decision="decline"]').click(); await finish(peer, declined.submission_id, refusal);
340
+ assert.equal(fs.existsSync(denied), false); check('dsh model respects a declined real ACP write operation');
176
341
  }
177
342
  await page.locator('#releaseControlBtn').click(); await page.waitForFunction(() => !window.hccHandoff.canControl);
178
343
  const followup = await api('POST', '/send', { peer, from: 'acceptance-local', submissionId: randomUUID(), body: 'No tool calls. Return INTERACTION_LOCAL_RETURN_OK.' });
179
344
  const after = await finish(peer, followup.submission_id); assert.equal(after.snapshot.sessionId, result.sessionId);
345
+ result.localReturn = { sessionId: after.snapshot.sessionId, submissionId: followup.submission_id };
180
346
  result.completed = true; check(provider + ' local return retains original provider session');
181
347
  }
182
348
  assert.deepEqual(evidence.pageErrors, []); assert.deepEqual(evidence.consoleErrors, []);
@@ -185,20 +351,55 @@ try {
185
351
  } catch (error) {
186
352
  evidence.error = error.message; process.exitCode = 1; console.error('REAL_INTERACTION_ACCEPTANCE_FAILED: ' + error.message);
187
353
  if (browser) for (const context of browser.contexts()) for (const page of context.pages()) {
188
- try { evidence.failureView = await page.evaluate(() => ({ active: window.hccHandoff?.active, canControl: window.hccHandoff?.canControl, sessions: window.hccHandoff?.sessions?.map(value => ({ id: value.id, type: value.type, status: value.status })), notice: document.getElementById('nativeNotice')?.textContent, handoff: document.getElementById('handoffController')?.textContent }));
354
+ try { evidence.failureView = await page.evaluate(() => ({ active: window.hccHandoff?.active, canControl: window.hccHandoff?.canControl, sessions: window.hccHandoff?.sessions?.map(value => ({ id: value.id, type: value.type, status: value.status })), notice: document.getElementById('nativeNotice')?.textContent, handoff: document.getElementById('handoffController')?.textContent,
355
+ nativeStatusBeforeDiagnosticRead: document.getElementById('nativeStatus')?.textContent,
356
+ approvalCardsBeforeDiagnosticRead: document.querySelectorAll('#nativeApprovals .codex-approval').length,
357
+ nativeObservations: window.__hccNativeAcceptanceObservations || [] }));
189
358
  const file = path.join(directory, 'failure.png'); await page.screenshot({ path: file }); evidence.screenshots.push(file);
359
+ const peer = evidence.failureView.active;
360
+ if (providers.some(provider => peer === 'live-' + provider)) {
361
+ try {
362
+ const native = await api('GET', '/workers/' + encodeURIComponent(peer) + '/state');
363
+ const requests = native.snapshot?.pendingApprovals || [];
364
+ evidence.failureNativeBackend = { source: 'native-service-read-after-failure', status: native.snapshot?.status,
365
+ hasSessionId: Boolean(native.snapshot?.sessionId), hasOwner: Boolean(native.owner), hasGeneration: Boolean(native.generation),
366
+ pendingCount: requests.length, pendingKinds: [...new Set(requests.map(request => request.kind))] };
367
+ const answered = answeredApprovals.get(peer);
368
+ evidence.failureCompletion = interactionCompletionDiagnostics(native, {
369
+ answeredRequest: answered?.request, submissionId: answered?.submission,
370
+ ownedTargets: [{ role: 'allowed', path: path.join(outside, peer.slice('live-'.length) + '-allowed.txt') },
371
+ { role: 'denied', path: path.join(directory, peer.slice('live-'.length) + '-denied', 'must-not-exist.txt') }],
372
+ });
373
+ evidence.failureWebRead = await page.evaluate(async expected => {
374
+ try {
375
+ const bridge = window.hccHandoff;
376
+ const response = await bridge.api('/api/sessions/' + encodeURIComponent(bridge.active) + '/native/state?root=' + encodeURIComponent(bridge.projectRoot));
377
+ const state = response.state || response, requests = state.pendingApprovals || [];
378
+ return { source: 'explicit-web-read-after-failure-may-refresh-state', connected: Boolean(state.connected),
379
+ status: state.status || null, errorCode: state.error?.code || null, hasSessionId: Boolean(state.sessionId),
380
+ ownerMatchesNative: state.owner === expected.owner, generationMatchesNative: state.generation === expected.generation,
381
+ sessionMatchesNative: state.sessionId === expected.sessionId,
382
+ pendingCount: requests.length, pendingKinds: [...new Set(requests.map(request => request.kind))] };
383
+ } catch (error) { return { source: 'explicit-web-read-after-failure-may-refresh-state', errorCode: error.code || null }; }
384
+ }, { owner: native.owner, generation: native.generation, sessionId: native.snapshot?.sessionId });
385
+ } catch (error) { evidence.failureNativeBackend = { errorCode: error.code || null }; }
386
+ }
190
387
  } catch {}
191
388
  }
192
389
  } finally {
193
- await browser?.close();
390
+ try { await browser?.close(); } catch (error) { evidence.browserCleanupError = redactSecrets(error.message); process.exitCode = 1; evidence.completed = false; }
194
391
  if (runtimeStarted) { try { hcc('down'); } catch {} }
195
- try { await service?.shutdown(); evidence.runtimeClosed = true; } catch (error) { evidence.cleanupError = error.message; process.exitCode = 1; evidence.completed = false; }
392
+ try { await service?.shutdown(); evidence.runtimeClosed = !readNativePointer(ctx); } catch (error) { evidence.cleanupError = redactSecrets(error.message); process.exitCode = 1; evidence.completed = false; }
196
393
  try { hcc('down'); } catch {}
197
394
  spawnSync(tmux, ['-L', socket, 'kill-server'], { stdio: 'ignore' });
198
395
  evidence.protectedConfigUnchanged = protectedHashes.every(([file, before]) => fs.existsSync(file) && digest(file) === before);
199
396
  if (!evidence.protectedConfigUnchanged) { process.exitCode = 1; evidence.completed = false; }
200
- for (const folder of [root, bin, codexHome, claudeHome, outside]) fs.rmSync(folder, { recursive: true, force: true });
201
- evidence.temporaryProviderHomesRemoved = !fs.existsSync(codexHome) && !fs.existsSync(claudeHome);
202
- evidence.finishedAt = new Date().toISOString(); fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(evidence, null, 2) + '\n', { mode: 0o600 });
397
+ for (const folder of [root, bin, taskHome, codexHome, claudeHome, dshHome, outside, path.join(directory, 'credential-commands')]) {
398
+ try { fs.rmSync(folder, { recursive: true, force: true }); }
399
+ catch (error) { (evidence.directoryCleanupErrors ||= []).push(redactSecrets(error.message)); process.exitCode = 1; evidence.completed = false; }
400
+ }
401
+ evidence.temporaryProviderHomesRemoved = !fs.existsSync(codexHome) && !fs.existsSync(claudeHome) && !fs.existsSync(dshHome);
402
+ if (!evidence.runtimeClosed || !evidence.temporaryProviderHomesRemoved) { process.exitCode = 1; evidence.completed = false; }
403
+ evidence.finishedAt = new Date().toISOString(); fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(redactSecrets(evidence), null, 2) + '\n', { mode: 0o600 });
203
404
  console.log(JSON.stringify({ evidence: output, completed: evidence.completed, protectedConfigUnchanged: evidence.protectedConfigUnchanged }));
204
405
  }
@@ -0,0 +1,80 @@
1
+ // Bounded metadata for the opt-in real-interaction fixture. Never persist
2
+ // provider bodies, raw tool input, environment values, or arbitrary paths.
3
+ import fs from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+
7
+ const methods = new Set(['session/request_permission', 'claude/canUseTool']);
8
+ const tools = new Set(['write', 'read', 'get', 'str_replace_editor', 'bash', 'Write', 'Read', 'Edit', 'Bash']);
9
+ const statuses = new Set(['ready', 'idle', 'busy', 'running', 'completed', 'failed', 'stopped', 'disconnected']);
10
+ const deliveryStates = new Set(['queued', 'pending', 'dispatching', 'dispatched', 'running', 'completed', 'failed', 'uncertain']);
11
+ const eventTypes = new Set(['item', 'turn', 'status', 'error', 'approval_requested', 'approval_resolved',
12
+ 'usage_update', 'agent_message', 'agent_thought', 'tool_call', 'tool_call_update', 'session_update']);
13
+ const kinds = new Set(['approval', 'permissions', 'userInput', 'elicitation']);
14
+ const safe = (value, allowed) => allowed.has(value) ? value : value == null ? null : 'other';
15
+ const identifier = value => Number.isSafeInteger(value) || (typeof value === 'string' && /^[\w.-]{1,120}$/.test(value)) ? value : null;
16
+ const timestamp = value => Number.isFinite(value) ? value : typeof value === 'string' && /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(value) ? value : null;
17
+
18
+ // rc.2 can ask once at tools/pre-execute and once inside write's explicit
19
+ // sandbox escalation. This recognizes only that same exact test-owned write;
20
+ // it never grants permission and is not a product approval policy.
21
+ export function isKnownDshEscalationApproval(first, next, { target, content, priorDecision, additionalAnswers = 0 } = {}) {
22
+ if (priorDecision !== 'accept' || additionalAnswers !== 0 || !first || !next || first.requestId === next.requestId) return false;
23
+ for (const key of ['executorId', 'sessionId', 'turnId']) if (typeof first[key] !== 'string' || !first[key] || first[key] !== next[key]) return false;
24
+ for (const request of [first, next]) {
25
+ if (identifier(request.requestId) === null) return false;
26
+ if (request.method !== 'session/request_permission' || request.kind !== 'approval' || request.truncated) return false;
27
+ const call = request.params?.toolCall, input = call?.rawInput;
28
+ if (call?.title !== 'write' || call.contextPending || call.contextTruncated || typeof call.toolCallId !== 'string' || identifier(call.toolCallId) === null) return false;
29
+ if (!input || input.file_path !== target || input.content !== content || input.sandbox_permissions !== 'danger-full-access') return false;
30
+ if (typeof input.justification !== 'string' || !input.justification.trim() || input.justification.length > 1000) return false;
31
+ if (!isDeepStrictEqual(Object.keys(input).sort(), ['content', 'file_path', 'justification', 'sandbox_permissions'])) return false;
32
+ if (!request.params.options?.some(option => option.kind === 'allow_once')) return false;
33
+ }
34
+ return first.params.toolCall.toolCallId === next.params.toolCall.toolCallId &&
35
+ isDeepStrictEqual(first.params.toolCall.rawInput, next.params.toolCall.rawInput);
36
+ }
37
+
38
+ function fileEvidence(target) {
39
+ const result = { role: target.role, exists: false, regularFile: false, sha256: null };
40
+ try {
41
+ const stat = fs.lstatSync(target.path);
42
+ result.exists = true; result.regularFile = stat.isFile();
43
+ if (stat.isFile() && stat.size <= 8192) {
44
+ const descriptor = fs.openSync(target.path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
45
+ try {
46
+ const current = fs.fstatSync(descriptor);
47
+ if (current.isFile() && current.dev === stat.dev && current.ino === stat.ino && current.size <= 8192) {
48
+ const content = Buffer.alloc(8193);
49
+ const bytes = fs.readSync(descriptor, content, 0, content.length, 0);
50
+ if (bytes <= 8192) result.sha256 = createHash('sha256').update(content.subarray(0, bytes)).digest('hex');
51
+ }
52
+ } finally { fs.closeSync(descriptor); }
53
+ }
54
+ } catch { /* An absent or unreadable test-owned target is not completion. */ }
55
+ return result;
56
+ }
57
+
58
+ export function interactionCompletionDiagnostics(state, { answeredRequest, submissionId, ownedTargets = [] } = {}) {
59
+ const pending = state?.snapshot?.pendingApprovals || [];
60
+ const targets = ownedTargets.filter(target => ['allowed', 'denied'].includes(target.role) && typeof target.path === 'string');
61
+ const differentRequest = Boolean(answeredRequest) && pending.some(request => request.requestId !== answeredRequest.requestId);
62
+ const delivery = state?.deliveries?.find(row => row.submission_id === submissionId);
63
+ const requests = pending.slice(0, 4).map(request => {
64
+ const tool = request.params?.toolCall, input = tool?.rawInput || request.params?.input;
65
+ const target = targets.find(target => input?.file_path === target.path || input?.path === target.path);
66
+ return { requestId: identifier(request.requestId), method: safe(request.method, methods), kind: safe(request.kind, kinds),
67
+ toolName: safe(tool?.title || request.params?.tool, tools), toolCallId: identifier(tool?.toolCallId),
68
+ targetExactOwned: Boolean(target), targetRole: target?.role || null };
69
+ });
70
+ return {
71
+ schemaVersion: 1, differentPendingRequest: differentRequest,
72
+ answeredRequestId: identifier(answeredRequest?.requestId), status: safe(state?.snapshot?.status, statuses),
73
+ deliveryState: safe(delivery?.state, deliveryStates), pendingCount: pending.length, pending: requests,
74
+ recentEvents: (state?.events || []).slice(-8).map(event => ({
75
+ type: safe(event.payload?.type || event.type, eventTypes),
76
+ timestamp: timestamp(event.created_at ?? event.timestamp ?? event.at),
77
+ })),
78
+ targets: targets.map(fileEvidence),
79
+ };
80
+ }
@@ -21,6 +21,7 @@ import { JsonRpcProcess } from '../lib/integrations/native/jsonrpc.mjs';
21
21
  import { startNativeService } from '../lib/runtime/native/service.mjs';
22
22
  import { nativeRequest } from '../lib/runtime/native/client.mjs';
23
23
 
24
+ if (!process.argv.includes('--run-live')) { console.log('Usage: node scripts/web-native-model-acceptance.mjs --run-live; requires existing Codex account, tmux and HCC_ACCEPTANCE_PLAYWRIGHT. Three bounded local/Web/local file turns.'); process.exit(0); }
24
25
  const repo = fileURLToPath(new URL('..', import.meta.url)), cli = path.join(repo, 'bin/hcc.mjs');
25
26
  const originalHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
26
27
  const protectedFiles = ['config.toml', 'auth.json'].map(name => path.join(originalHome, name)).filter(file => fs.existsSync(file));
@@ -30,21 +31,32 @@ const source = fs.readFileSync(path.join(originalHome, 'config.toml'), 'utf8');
30
31
  const provider = JSON.parse(source.match(/^model_provider\s*=\s*(".*")\s*$/m)?.[1] || '"openai"');
31
32
  const model = JSON.parse(source.match(/^model\s*=\s*(".*")\s*$/m)?.[1] || 'null');
32
33
  const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-live-handoff-')));
33
- const root = path.join(directory, 'project'), home = path.join(directory, 'codex-home'), bin = path.join(directory, 'bin');
34
- for (const folder of [root, home, bin, path.join(root, '.hello-cc')]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
34
+ fs.chmodSync(directory, 0o700);
35
+ const root = path.join(directory, 'project'), home = path.join(directory, 'codex-home'), bin = path.join(directory, 'bin'), taskHome = path.join(directory, 'user-home');
36
+ for (const folder of [root, home, bin, taskHome, path.join(root, '.hello-cc')]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
35
37
  const sections = source.split(/(?=^\[)/m), headings = ['model_providers.' + provider, 'model_providers."' + provider + '"'];
36
38
  const config = ['model', 'model_provider', 'model_reasoning_effort'].map(key => source.match(new RegExp('^' + key + '\\s*=.*$', 'm'))?.[0]).filter(Boolean);
37
39
  for (const section of sections.slice(1)) {
38
40
  const heading = /^\[([^\]]+)\]/.exec(section)?.[1];
39
41
  if (headings.some(prefix => heading === prefix || heading?.startsWith(prefix + '.'))) config.push(section);
40
42
  }
41
- fs.writeFileSync(path.join(home, 'config.toml'), config.join('\n') + '\n', { mode: 0o600 });
43
+ const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
44
+ let authIndex = 0;
45
+ const isolatedConfig = config.join('\n').split('\n').map(line => {
46
+ const match = /^(\s*command\s*=\s*)("(?:[^"\\]|\\.)*"|'[^']*')(\s*(?:#.*)?)$/.exec(line);
47
+ if (!match) return line;
48
+ const original = match[2].startsWith('"') ? JSON.parse(match[2]) : match[2].slice(1, -1);
49
+ const wrapper = path.join(bin, 'credential-command-' + ++authIndex);
50
+ fs.writeFileSync(wrapper, '#!/bin/sh\nexport HOME=' + quote(os.homedir()) + '\nexec ' + quote(original) + ' "$@"\n', { mode: 0o700 });
51
+ return match[1] + JSON.stringify(wrapper) + match[3];
52
+ }).join('\n');
53
+ fs.writeFileSync(path.join(home, 'config.toml'), isolatedConfig + '\n', { mode: 0o600 });
42
54
  if (fs.existsSync(path.join(originalHome, 'auth.json'))) fs.writeFileSync(path.join(home, 'auth.json'), fs.readFileSync(path.join(originalHome, 'auth.json')), { mode: 0o600 });
43
55
  const socket = 'hcc-live-' + randomUUID(), tmux = process.env.HCC_ACCEPTANCE_TMUX || '/opt/homebrew/bin/tmux';
44
56
  assert.ok(fs.existsSync(tmux), 'Set HCC_ACCEPTANCE_TMUX to the installed tmux executable');
45
- fs.writeFileSync(path.join(bin, 'tmux'), '#!/bin/sh\nexec ' + JSON.stringify(tmux) + ' -L ' + JSON.stringify(socket) + ' "$@"\n', { mode: 0o700 });
57
+ fs.writeFileSync(path.join(bin, 'tmux'), '#!/bin/sh\nexec ' + quote(tmux) + ' -L ' + quote(socket) + ' "$@"\n', { mode: 0o700 });
46
58
  const env = { ...process.env, PATH: bin + ':' + path.dirname(process.execPath) + ':' + process.env.PATH,
47
- CODEX_HOME: home, HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1', HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID() };
59
+ HOME: taskHome, CODEX_HOME: home, HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1', HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID() };
48
60
  for (const key of Object.keys(env)) if (key.startsWith('HCC_') && !['HCC_WEB_TOKEN','HCC_NO_AUTO_INSTALL_TMUX','HCC_SHIM_ENSURED','HCC_SHIM_NO_ATTACH'].includes(key)) delete env[key];
49
61
  function hcc(...args) {
50
62
  const result = spawnSync(process.execPath, [cli, '--root', root, ...args], { cwd: root, env, encoding: 'utf8', timeout: 30000 });
@@ -58,6 +70,8 @@ fs.writeFileSync(path.join(root, 'input.json'), JSON.stringify({ value: 41 }));
58
70
  const events = createEventHelpers(), bindings = createPeerBindingStore(events), peers = createPeerHelpers({ ...events, now: () => Math.floor(Date.now() / 1000) });
59
71
  const deps = { ...events, ...bindings, ...peers, ...createMessageStore(events), connect, detectBranch: () => '',
60
72
  liveProcessIdentity: pid => inspectProcessIdentity(pid).identity };
73
+ const sourceHashes = () => Object.fromEntries(fs.readdirSync(path.join(repo, 'lib'), { recursive: true }).filter(name => fs.statSync(path.join(repo, 'lib', name)).isFile()).sort().map(name => ['lib/' + name, hash(path.join(repo, 'lib', name))]));
74
+ const frozenSources = sourceHashes();
61
75
  const evidence = { model, directory, stages: [], pageErrors: [], consoleErrors: [], screenshots: [], modelInferenceCalled: true,
62
76
  browser: 'Browser plugin not available; isolated Chrome and Playwright', completed: false };
63
77
  let service, runtimeStarted = false, browser, child;
@@ -120,7 +134,8 @@ try {
120
134
  assert.equal(evidence.pageErrors.length, 0); assert.equal(evidence.consoleErrors.length, 0);
121
135
  const mcpEvents = final.events.filter(event => event.payload?.type === 'item' && event.payload.item?.type === 'mcpToolCall');
122
136
  evidence.mcpToolEvents = mcpEvents.length;
123
- evidence.completed = true; console.log('LIVE_LOCAL_WEB_LOCAL_OK');
137
+ assert.deepEqual(sourceHashes(), frozenSources, 'Implementation must remain unchanged during acceptance');
138
+ evidence.sourceFiles = frozenSources; evidence.completed = true; console.log('LIVE_LOCAL_WEB_LOCAL_OK');
124
139
  } catch (error) {
125
140
  evidence.error = error.message;
126
141
  process.exitCode = 1; console.error('LIVE_HANDOFF_FAILED: ' + error.message);
@@ -133,6 +148,6 @@ try {
133
148
  evidence.protectedConfigUnchanged = protectedHashes.every(([file, original]) => fs.existsSync(file) && hash(file) === original);
134
149
  if (!evidence.protectedConfigUnchanged) { process.exitCode = 1; evidence.completed = false; }
135
150
  fs.writeFileSync(path.join(directory, 'evidence.json'), JSON.stringify(evidence, null, 2));
136
- for (const folder of [root, home, bin]) fs.rmSync(folder, { recursive: true, force: true });
151
+ for (const folder of [root, home, bin, taskHome]) fs.rmSync(folder, { recursive: true, force: true });
137
152
  console.log(JSON.stringify({ evidence: path.join(directory, 'evidence.json'), completed: evidence.completed, protectedConfigUnchanged: evidence.protectedConfigUnchanged }));
138
153
  }