@logicseek/hello-cc 1.0.1 → 1.0.2

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 (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
@@ -0,0 +1,98 @@
1
+ // Installed Codex protocol and scoped MCP discovery. No model turn, account
2
+ // configuration, or existing task is touched; every file belongs to this run.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { initSchema } from '../lib/db/schema.mjs';
11
+ import { createEventHelpers } from '../lib/db/events.mjs';
12
+ import { createPeerHelpers } from '../lib/core/peers/peer-helpers.mjs';
13
+ import { createPeerBindingStore } from '../lib/db/stores/peers.mjs';
14
+ import { createCodexSessions } from '../lib/web/codex-sessions.mjs';
15
+ import { createCodexAppServer } from '../lib/web/codex-app-server.mjs';
16
+ import { createScopedMcpConfig } from '../lib/mcp/scope.mjs';
17
+ import { setTimeout as delay } from 'node:timers/promises';
18
+
19
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-installed-mcp-'));
20
+ const root = path.join(directory, 'project'), codexHome = path.join(directory, 'codex-home');
21
+ fs.mkdirSync(root); fs.mkdirSync(codexHome);
22
+ const ctx = { root, dbPath: path.join(root, 'mesh.db') };
23
+ const connect = () => new DatabaseSync(ctx.dbPath);
24
+ const db = connect(); initSchema(db); db.close();
25
+ const now = () => Math.floor(Date.now() / 1000);
26
+ const events = createEventHelpers({ now }), peers = createPeerHelpers({ now, ...events });
27
+ const bindings = createPeerBindingStore({ now, ...events });
28
+ const sessions = new Map();
29
+ let child;
30
+ const manager = createCodexSessions({ sessions, sessionKey: (context, id) => context.root + '\0' + id,
31
+ nextProjectSessionId: () => 'installed-mcp-qa', connectWebProject: connect,
32
+ ...peers, ...bindings, ...events, now, observePeerEvidence: () => ({ state: 'dead' }),
33
+ broadcast() {}, closeSessionClients() {},
34
+ mcpConfigFactory: options => createScopedMcpConfig({ ...options,
35
+ cliPath: fileURLToPath(new URL('../bin/hcc.mjs', import.meta.url)) }),
36
+ adapterFactory: options => createCodexAppServer({ ...options,
37
+ env: { ...options.env, CODEX_HOME: codexHome, HCC_SHIM_NO_ATTACH: '1' },
38
+ spawnProcess: (...args) => { child = spawn(...args); return child; }, requestTimeoutMs: 10000 }) });
39
+
40
+ let requestSequence = 0;
41
+ function rpc(method, params) {
42
+ const requestId = 'qa-protocol-' + ++requestSequence;
43
+ return new Promise((resolve, reject) => {
44
+ let buffer = '';
45
+ const cleanup = () => { clearTimeout(timer); child.stdout.off('data', onData); child.off('exit', onExit); };
46
+ const onExit = () => { cleanup(); reject(new Error('Installed App Server exited before MCP discovery')); };
47
+ const onData = data => {
48
+ buffer += data;
49
+ let newline;
50
+ while ((newline = buffer.indexOf('\n')) >= 0) {
51
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
52
+ let frame; try { frame = JSON.parse(line); } catch { continue; }
53
+ if (frame.id === requestId) { cleanup(); resolve(frame); }
54
+ }
55
+ };
56
+ const timer = setTimeout(() => { cleanup(); reject(new Error('Scoped MCP discovery timed out')); }, 15000);
57
+ child.stdout.on('data', onData); child.once('exit', onExit);
58
+ child.stdin.write(JSON.stringify({ id: requestId, method, params }) + '\n');
59
+ });
60
+ }
61
+
62
+ try {
63
+ const session = await manager.startCodexSession({ kind: 'codex', projectCtx: ctx });
64
+ let server;
65
+ for (let attempt = 0; attempt < 20; attempt += 1) {
66
+ const frame = await rpc('mcpServerStatus/list', { threadId: session.binding.provider_session_id,
67
+ serverName: 'hello_cc_scoped', limit: 20 });
68
+ assert.equal(frame.error, undefined, frame.error?.message);
69
+ server = (frame.result?.data || []).find(entry => entry.name === 'hello_cc_scoped');
70
+ if (Object.keys(server?.tools || {}).length) break;
71
+ await delay(250);
72
+ }
73
+ const evidence = { isolatedHome: true, inferenceCalled: false, initialize: 'passed', threadStart: 'passed',
74
+ userAgent: session.adapter.snapshot().metadata?.userAgent || null,
75
+ mcpConfigured: Boolean(session.mcpCapability), serverDiscovered: Boolean(server),
76
+ toolCount: Object.keys(server?.tools || {}).length, serverFields: Object.keys(server || {}),
77
+ startupStatus: server?.startupStatus || server?.status || null, authStatus: server?.authStatus || null,
78
+ serverError: server?.error || server?.startupError || null,
79
+ mcpNotifications: session.adapter.snapshot().events.filter(event => /mcp/i.test(event.method)).map(event => ({ method: event.method,
80
+ status: event.params.status || null,
81
+ error: String(event.params.error || '').split(session.mcpCapability.config.env.HCC_MCP_BOOTSTRAP_TOKEN).join('[redacted]') })) };
82
+ assert.equal(evidence.serverDiscovered, true, 'Installed Codex did not discover its thread-scoped MCP server');
83
+ assert.equal(evidence.toolCount, 10, 'Installed Codex did not discover all scoped tools');
84
+ const result = await rpc('mcpServer/tool/call', { threadId: session.binding.provider_session_id,
85
+ server: 'hello_cc_scoped', tool: 'hcc_state', arguments: {} });
86
+ assert.equal(result.error, undefined, result.error?.message);
87
+ assert.equal(result.result.isError, false, 'Installed Codex could not execute a scoped coordination read');
88
+ assert.equal(result.result.structuredContent.ok, true);
89
+ assert.equal(result.result.structuredContent.peer, session.peerId);
90
+ evidence.mcpBusinessRead = 'passed';
91
+ console.log(JSON.stringify(evidence));
92
+ const history = await manager.listCodexThreads(ctx, { limit: 5 });
93
+ assert.ok(Array.isArray(history.threads));
94
+ console.log('INSTALLED_PROTOCOL_MCP_OK');
95
+ } finally {
96
+ for (const session of sessions.values()) { await session.adapter?.close(); session.mcpCapability?.dispose(); }
97
+ fs.rmSync(directory, { recursive: true, force: true });
98
+ }
@@ -0,0 +1,204 @@
1
+ // Opt-in, real model + browser acceptance for original native workers.
2
+ // Uses isolated project/provider homes/socket; no query or request injection.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import net from 'node:net';
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { setTimeout as delay } from 'node:timers/promises';
13
+ import { initSchema } from '../lib/db/schema.mjs';
14
+ import { createEventHelpers } from '../lib/db/events.mjs';
15
+ import { createPeerHelpers } from '../lib/core/peers/peer-helpers.mjs';
16
+ import { createPeerBindingStore } from '../lib/db/stores/peers.mjs';
17
+ import { createMessageStore } from '../lib/core/coordination/messages.mjs';
18
+ import { inspectProcessIdentity } from '../lib/process/identity.mjs';
19
+ import { createNativeAdapter } from '../lib/integrations/native/index.mjs';
20
+ import { startNativeService } from '../lib/runtime/native/service.mjs';
21
+ import { nativeRequest } from '../lib/runtime/native/client.mjs';
22
+
23
+ const args = process.argv.slice(2);
24
+ const option = (name, fallback) => args.includes(name) ? args[args.indexOf(name) + 1] : fallback;
25
+ 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]');
27
+ 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.');
28
+ process.exit(0);
29
+ }
30
+ const providers = option('--provider', 'codex') === 'all' ? ['codex', 'claude'] : [option('--provider', 'codex')];
31
+ assert.ok(providers.every(value => ['codex', 'claude'].includes(value)), 'unknown provider');
32
+ const repo = fileURLToPath(new URL('..', import.meta.url));
33
+ const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-live-web-interactions-')));
34
+ fs.chmodSync(directory, 0o700);
35
+ const root = path.join(directory, 'project'), bin = path.join(directory, 'bin');
36
+ const codexHome = path.join(directory, 'codex-home'), claudeHome = path.join(directory, 'claude-home');
37
+ 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 });
39
+ const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
40
+ const originalHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
41
+ const protectedFiles = ['config.toml', 'auth.json'].map(name => path.join(originalHome, name)).filter(file => fs.existsSync(file));
42
+ 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))]));
44
+ const evidence = { startedAt: new Date().toISOString(), directory, sourceFiles: sourceHashes(), providers: {}, checks: [], screenshots: [], pageErrors: [], consoleErrors: [], completed: false,
45
+ browser: 'Browser plugin not available; isolated installed Chrome and Playwright', modelInferenceCalled: false };
46
+ const output = path.resolve(option('--output', path.join(directory, 'evidence.json')));
47
+ const tmux = process.env.HCC_ACCEPTANCE_TMUX || '/opt/homebrew/bin/tmux';
48
+ const socket = 'hcc-interactions-' + randomUUID();
49
+ const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
50
+ 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,
52
+ HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1', HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID() };
53
+ 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
+ const ctx = { root, dbPath: path.join(root, '.hello-cc', 'mesh.db') };
55
+ const connect = () => { const db = new DatabaseSync(ctx.dbPath); initSchema(db); return db; };
56
+ const events = createEventHelpers(), bindings = createPeerBindingStore(events), peers = createPeerHelpers({ ...events, now: () => Math.floor(Date.now() / 1000) });
57
+ const deps = { ...events, ...bindings, ...peers, ...createMessageStore(events), connect, detectBranch: () => '', liveProcessIdentity: pid => inspectProcessIdentity(pid).identity };
58
+ const api = (method, route, body) => nativeRequest(ctx, method, route, body, { timeoutMs: 30000 });
59
+ function hcc(...params) {
60
+ const result = spawnSync(process.execPath, [path.join(repo, 'bin/hcc.mjs'), '--root', root, ...params], { cwd: root, env, encoding: 'utf8', timeout: 30000 });
61
+ if (result.status !== 0) throw new Error('Isolated HCC CLI failed: ' + params[0]);
62
+ }
63
+ function check(name, details = {}) { evidence.checks.push({ name, passed: true, ...details }); console.log('PASS ' + name); }
64
+ async function until(predicate, label, timeout = 180000) {
65
+ const deadline = Date.now() + timeout; let notice = Date.now() + 20000;
66
+ while (Date.now() < deadline) {
67
+ const result = await predicate(); if (result) return result;
68
+ if (Date.now() >= notice) { console.log('WAIT ' + label); notice += 20000; }
69
+ await delay(150);
70
+ }
71
+ throw new Error('Timed out: ' + label);
72
+ }
73
+ let service, browser, runtimeStarted = false;
74
+ try {
75
+ assert.ok(fs.existsSync(tmux), 'tmux must be installed');
76
+ assert.ok(process.env.HCC_ACCEPTANCE_PLAYWRIGHT, 'Set HCC_ACCEPTANCE_PLAYWRIGHT');
77
+ if (providers.includes('codex')) {
78
+ const source = fs.readFileSync(path.join(originalHome, 'config.toml'), 'utf8'); let section = '';
79
+ const config = source.split('\n').filter(line => {
80
+ const header = line.match(/^\s*\[\[?([^\]]+)\]\]?/); if (header) section = header[1].split('.')[0].replaceAll('"', '').replaceAll("'", '');
81
+ return section ? section === 'model_providers' : /^\s*(?:model(?:_provider|_reasoning_effort|_reasoning_summary|_verbosity|_catalog_json)?|cli_auth_credentials_store)\s*=/.test(line);
82
+ }).join('\n');
83
+ fs.writeFileSync(path.join(codexHome, 'config.toml'), config + '\n', { mode: 0o600 });
84
+ 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
+ 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
+ }
87
+ if (providers.includes('claude') && option('--claude-package')) {
88
+ const installed = fs.realpathSync(option('--claude-package'));
89
+ const pkg = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8'));
90
+ assert.equal(pkg.name, '@anthropic-ai/claude-agent-sdk');
91
+ const scoped = path.join(root, 'node_modules/@anthropic-ai'); fs.mkdirSync(scoped, { recursive: true });
92
+ fs.symlinkSync(installed, path.join(scoped, 'claude-agent-sdk'), 'dir');
93
+ evidence.providers.claude = { sdkVersion: pkg.version, dependencyResolution: 'default loader: optional SDK installed in isolated worker project; no query injection' };
94
+ }
95
+ hcc('up', '--no-discover', '--no-guidance');
96
+ 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') } : {}) });
99
+ 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
+ hcc('web', '--local', '--port', String(port), '--no-discover', '--no-guidance'); runtimeStarted = true;
101
+ const { chromium } = await import(process.env.HCC_ACCEPTANCE_PLAYWRIGHT);
102
+ browser = await chromium.launch({ headless: true, ...(process.env.HCC_ACCEPTANCE_CHROME ? { executablePath: process.env.HCC_ACCEPTANCE_CHROME } : {}) });
103
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
104
+ page.on('dialog', dialog => dialog.accept());
105
+ page.on('pageerror', error => evidence.pageErrors.push(error.message));
106
+ page.on('console', message => { if (message.type() === 'error') evidence.consoleErrors.push(message.text()); });
107
+ await page.goto('http://127.0.0.1:' + port + '/?token=' + env.HCC_WEB_TOKEN);
108
+ async function state(peer) { return api('GET', '/workers/' + peer + '/state'); }
109
+ async function finish(peer, submission) {
110
+ return until(async () => {
111
+ const value = await state(peer), delivery = value.deliveries.find(row => row.submission_id === submission);
112
+ if (['failed', 'uncertain'].includes(delivery?.state)) throw new Error(peer + ' delivery ended as ' + delivery.state);
113
+ if (delivery?.state === 'completed') { evidence.modelInferenceCalled = true; return value; }
114
+ return false;
115
+ }, peer + ' real model completion');
116
+ }
117
+ async function selectPeer(peer) {
118
+ await page.waitForFunction(() => Boolean(window.hccHandoff?.session), null, { timeout: 15000 });
119
+ if (await page.evaluate(() => window.hccHandoff.active) !== peer) await page.locator('#sessions [data-id="' + peer + '"] .session-select').click();
120
+ await page.waitForFunction(peer => window.hccHandoff?.session?.id === peer, peer, { timeout: 15000 });
121
+ await page.waitForFunction(() => Boolean(window.hccHandoff.actionToken), null, { timeout: 15000 });
122
+ if (!await page.evaluate(() => window.hccHandoff.canControl)) await page.locator('#claimControlBtn').click();
123
+ await page.waitForFunction(() => window.hccHandoff.canControl, null, { timeout: 15000 });
124
+ }
125
+ async function submit(peer, prompt) {
126
+ const before = new Set((await state(peer)).deliveries.map(row => row.submission_id));
127
+ await page.locator('#nativeDraft').fill(prompt); await page.locator('#nativeSend').click();
128
+ return until(async () => (await state(peer)).deliveries.find(row => row.submission_id.startsWith('s_') && !before.has(row.submission_id)), peer + ' browser submission', 15000);
129
+ }
130
+ async function pending(peer, kind) {
131
+ return until(async () => { const value = await state(peer); const request = value.snapshot.pendingApprovals?.find(request => request.kind === kind); if (request) evidence.modelInferenceCalled = true; return request; }, peer + ' ' + kind);
132
+ }
133
+ async function shot(name) { const target = path.join(directory, name + '.png'); await page.screenshot({ path: target }); evidence.screenshots.push(target); }
134
+ for (const provider of providers) {
135
+ const peer = 'live-' + provider, result = evidence.providers[provider] ||= {}, allowed = path.join(outside, provider + '-allowed.txt');
136
+ const denied = path.join(directory, provider + '-denied', 'must-not-exist.txt');
137
+ fs.mkdirSync(path.dirname(denied), { mode: 0o700 });
138
+ await selectPeer(peer);
139
+ if (provider === 'codex') {
140
+ const delivery = await submit(peer, 'Use request_user_input now to ask me which marker to write: Cobalt or Amber, with those exact option labels. Wait for my choice. Then call request_permissions to request write access only to ' + allowed + '. Wait for approval. After approval write the chosen marker to that exact file and end with INTERACTION_ACCEPTED_OK. Use only bounded file operations. Do not bypass permission checks or use command escalation.');
141
+ const question = await pending(peer, 'userInput');
142
+ result.question = { method: question.method, requestId: question.requestId, sessionId: question.sessionId, turnId: question.turnId };
143
+ await page.locator('#nativeApprovals [data-question="0"]').waitFor({ timeout: 15000 });
144
+ const choices = await page.locator('#nativeApprovals [data-question="0"] option').allTextContents();
145
+ const cobalt = choices.findIndex(value => value.includes('Cobalt')); assert.ok(cobalt > 0);
146
+ await page.locator('#nativeApprovals [data-question="0"]').selectOption(String(cobalt - 1)); await shot('codex-real-question');
147
+ await page.locator('#nativeApprovals button[data-decision="accept"]').click();
148
+ const permission = await pending(peer, 'permissions');
149
+ assert.equal(permission.sessionId, question.sessionId); assert.equal(permission.turnId, question.turnId);
150
+ const requested = JSON.stringify(permission.params.permissions); assert.ok(requested.includes(allowed));
151
+ result.permission = { method: permission.method, requestId: permission.requestId, sessionId: permission.sessionId, turnId: permission.turnId, requested: permission.params.permissions };
152
+ await page.locator('#nativeApprovals [data-scope]').waitFor({ timeout: 15000 });
153
+ const permissionFields = page.locator('#nativeApprovals input[data-permission]');
154
+ assert.equal(await permissionFields.count(), 1, 'request must contain only the single bounded filesystem permission');
155
+ await permissionFields.check(); await page.locator('#nativeApprovals [data-scope]').selectOption('turn'); await shot('codex-real-permission');
156
+ await page.locator('#nativeApprovals button[data-decision="accept"]').click(); await finish(peer, delivery.submission_id);
157
+ assert.equal(fs.readFileSync(allowed, 'utf8').trim(), 'Cobalt');
158
+ result.sessionId = question.sessionId; check('Codex model question and exact turn-scoped permission answered through Web on the original turn');
159
+ const declined = await submit(peer, 'Call request_permissions for write access only to ' + denied + '. If approval is declined, do not try to write and end normally with INTERACTION_DECLINED_OK. Do not use escalation or any other paths.');
160
+ const request = await pending(peer, 'permissions'); assert.equal(request.sessionId, result.sessionId);
161
+ await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await page.locator('#nativeApprovals button[data-decision="decline"]').click();
162
+ await finish(peer, declined.submission_id); assert.equal(fs.existsSync(denied), false);
163
+ check('Codex model respects a declined real filesystem permission');
164
+ } else {
165
+ 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
+ const request = await pending(peer, 'approval'); assert.equal(request.method, 'claude/canUseTool'); assert.equal(request.params.tool, 'Write');
167
+ assert.equal(request.params.input.file_path, allowed); result.permission = { method: request.method, tool: request.params.tool, requestId: request.requestId, turnId: request.turnId };
168
+ await page.locator('#nativeApprovals button[data-decision="accept"]').waitFor({ timeout: 15000 }); await shot('claude-real-permission');
169
+ await page.locator('#nativeApprovals button[data-decision="accept"]').click(); await finish(peer, delivery.submission_id);
170
+ assert.equal(fs.readFileSync(allowed, 'utf8').trim(), 'CLAUDE_APPROVED_OK');
171
+ result.sessionId = (await state(peer)).snapshot.sessionId; check('Default-loaded Claude SDK real Write approval returns to the original model turn');
172
+ const declined = await submit(peer, 'Use the Write tool to write CLAUDE_DENIED to ' + denied + '. If permission is declined, do not retry or use any workaround and finish normally.');
173
+ const refusal = await pending(peer, 'approval'); assert.equal(refusal.params.tool, 'Write'); assert.equal(refusal.params.input.file_path, denied);
174
+ await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await page.locator('#nativeApprovals button[data-decision="decline"]').click();
175
+ await finish(peer, declined.submission_id); assert.equal(fs.existsSync(denied), false); check('Claude model respects a declined real Write operation');
176
+ }
177
+ await page.locator('#releaseControlBtn').click(); await page.waitForFunction(() => !window.hccHandoff.canControl);
178
+ const followup = await api('POST', '/send', { peer, from: 'acceptance-local', submissionId: randomUUID(), body: 'No tool calls. Return INTERACTION_LOCAL_RETURN_OK.' });
179
+ const after = await finish(peer, followup.submission_id); assert.equal(after.snapshot.sessionId, result.sessionId);
180
+ result.completed = true; check(provider + ' local return retains original provider session');
181
+ }
182
+ assert.deepEqual(evidence.pageErrors, []); assert.deepEqual(evidence.consoleErrors, []);
183
+ assert.deepEqual(sourceHashes(), evidence.sourceFiles); check('zero browser errors; current implementation unchanged during real acceptance');
184
+ evidence.completed = true;
185
+ } catch (error) {
186
+ evidence.error = error.message; process.exitCode = 1; console.error('REAL_INTERACTION_ACCEPTANCE_FAILED: ' + error.message);
187
+ 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 }));
189
+ const file = path.join(directory, 'failure.png'); await page.screenshot({ path: file }); evidence.screenshots.push(file);
190
+ } catch {}
191
+ }
192
+ } finally {
193
+ await browser?.close();
194
+ 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; }
196
+ try { hcc('down'); } catch {}
197
+ spawnSync(tmux, ['-L', socket, 'kill-server'], { stdio: 'ignore' });
198
+ evidence.protectedConfigUnchanged = protectedHashes.every(([file, before]) => fs.existsSync(file) && digest(file) === before);
199
+ 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 });
203
+ console.log(JSON.stringify({ evidence: output, completed: evidence.completed, protectedConfigUnchanged: evidence.protectedConfigUnchanged }));
204
+ }
@@ -0,0 +1,138 @@
1
+ // Opt-in installed-model acceptance. Uses a disposable Codex home, project,
2
+ // tmux socket and browser profile; preserves the original account configuration.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import net from 'node:net';
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { setTimeout as delay } from 'node:timers/promises';
13
+ import { initSchema } from '../lib/db/schema.mjs';
14
+ import { createEventHelpers } from '../lib/db/events.mjs';
15
+ import { createPeerHelpers } from '../lib/core/peers/peer-helpers.mjs';
16
+ import { createPeerBindingStore } from '../lib/db/stores/peers.mjs';
17
+ import { createMessageStore } from '../lib/core/coordination/messages.mjs';
18
+ import { inspectProcessIdentity } from '../lib/process/identity.mjs';
19
+ import { createCodexAdapter } from '../lib/integrations/native/codex.mjs';
20
+ import { JsonRpcProcess } from '../lib/integrations/native/jsonrpc.mjs';
21
+ import { startNativeService } from '../lib/runtime/native/service.mjs';
22
+ import { nativeRequest } from '../lib/runtime/native/client.mjs';
23
+
24
+ const repo = fileURLToPath(new URL('..', import.meta.url)), cli = path.join(repo, 'bin/hcc.mjs');
25
+ const originalHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
26
+ const protectedFiles = ['config.toml', 'auth.json'].map(name => path.join(originalHome, name)).filter(file => fs.existsSync(file));
27
+ const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
28
+ const protectedHashes = protectedFiles.map(file => [file, hash(file)]);
29
+ const source = fs.readFileSync(path.join(originalHome, 'config.toml'), 'utf8');
30
+ const provider = JSON.parse(source.match(/^model_provider\s*=\s*(".*")\s*$/m)?.[1] || '"openai"');
31
+ const model = JSON.parse(source.match(/^model\s*=\s*(".*")\s*$/m)?.[1] || 'null');
32
+ 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 });
35
+ const sections = source.split(/(?=^\[)/m), headings = ['model_providers.' + provider, 'model_providers."' + provider + '"'];
36
+ const config = ['model', 'model_provider', 'model_reasoning_effort'].map(key => source.match(new RegExp('^' + key + '\\s*=.*$', 'm'))?.[0]).filter(Boolean);
37
+ for (const section of sections.slice(1)) {
38
+ const heading = /^\[([^\]]+)\]/.exec(section)?.[1];
39
+ if (headings.some(prefix => heading === prefix || heading?.startsWith(prefix + '.'))) config.push(section);
40
+ }
41
+ fs.writeFileSync(path.join(home, 'config.toml'), config.join('\n') + '\n', { mode: 0o600 });
42
+ 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
+ const socket = 'hcc-live-' + randomUUID(), tmux = process.env.HCC_ACCEPTANCE_TMUX || '/opt/homebrew/bin/tmux';
44
+ 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 });
46
+ 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() };
48
+ 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
+ function hcc(...args) {
50
+ const result = spawnSync(process.execPath, [cli, '--root', root, ...args], { cwd: root, env, encoding: 'utf8', timeout: 30000 });
51
+ if (result.status !== 0) throw new Error('Isolated HCC CLI failed: ' + args[0]);
52
+ return result.stdout;
53
+ }
54
+ const ctx = { root, dbPath: path.join(root, '.hello-cc', 'mesh.db') };
55
+ const connect = () => { const db = new DatabaseSync(ctx.dbPath); db.exec('PRAGMA busy_timeout=5000'); return db; };
56
+ const db = connect(); initSchema(db); db.close();
57
+ fs.writeFileSync(path.join(root, 'input.json'), JSON.stringify({ value: 41 }));
58
+ const events = createEventHelpers(), bindings = createPeerBindingStore(events), peers = createPeerHelpers({ ...events, now: () => Math.floor(Date.now() / 1000) });
59
+ const deps = { ...events, ...bindings, ...peers, ...createMessageStore(events), connect, detectBranch: () => '',
60
+ liveProcessIdentity: pid => inspectProcessIdentity(pid).identity };
61
+ const evidence = { model, directory, stages: [], pageErrors: [], consoleErrors: [], screenshots: [], modelInferenceCalled: true,
62
+ browser: 'Browser plugin not available; isolated Chrome and Playwright', completed: false };
63
+ let service, runtimeStarted = false, browser, child;
64
+ const api = (method, route, body) => nativeRequest(ctx, method, route, body);
65
+ async function complete(submissionId) {
66
+ const deadline = Date.now() + 180000;
67
+ while (Date.now() < deadline) {
68
+ const state = await api('GET', '/workers/live-worker/state');
69
+ const delivery = state.deliveries.find(row => row.submission_id === submissionId);
70
+ if (delivery?.state === 'completed') return state;
71
+ if (['failed', 'uncertain'].includes(delivery?.state)) throw new Error('Model delivery ended in ' + delivery.state);
72
+ if (state.snapshot.pendingApprovals?.length) throw new Error('Model requested an unexpected interactive operation during bounded file acceptance');
73
+ await delay(250);
74
+ }
75
+ throw new Error('Model continuous-task acceptance timed out');
76
+ }
77
+ function output() { return JSON.parse(fs.readFileSync(path.join(root, 'handoff.json'), 'utf8')); }
78
+ try {
79
+ hcc('up', '--no-discover', '--no-guidance');
80
+ service = await startNativeService(ctx, deps, { adapterFactory: (provider, options) => {
81
+ assert.equal(provider, 'codex');
82
+ return createCodexAdapter({ ...options, env: { ...options.env, ...env, HCC_PEER: options.env.HCC_PEER, HCC_ROOT: root, HCC_DB: ctx.dbPath, HCC_NATIVE_OWNER: options.executorId },
83
+ rpcFactory: config => { const rpc = new JsonRpcProcess(config); const start = rpc.start.bind(rpc); rpc.start = async () => { await start(); child = rpc.child; }; return rpc; } });
84
+ } });
85
+ const worker = await api('POST', '/workers', { peer: 'live-worker', provider: 'codex' });
86
+ const identity = { sessionId: worker.sessionId, pid: child.pid };
87
+ const local = await api('POST', '/send', { peer: 'live-worker', from: 'shell', submissionId: 'live_local_' + randomUUID(), body:
88
+ 'Use the hello_cc_scoped hcc_state MCP tool once. Read input.json. Write handoff.json as JSON with value increased by one and phases ["local"]. Do only these bounded project-file operations. End with LOCAL_PHASE_OK.' });
89
+ await complete(local.submission_id); assert.deepEqual(output(), { value: 42, phases: ['local'] });
90
+ evidence.stages.push({ stage: 'local-model-turn', passed: true, ...identity }); console.log('LOCAL_MODEL_PHASE_OK');
91
+ 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)); }); });
92
+ hcc('web', '--local', '--port', String(port), '--no-discover', '--no-guidance'); runtimeStarted = true;
93
+ const playwright = process.env.HCC_ACCEPTANCE_PLAYWRIGHT;
94
+ assert.ok(playwright, 'Set HCC_ACCEPTANCE_PLAYWRIGHT to an installed Playwright module');
95
+ const { chromium } = await import(playwright);
96
+ browser = await chromium.launch({ headless: true, ...(process.env.HCC_ACCEPTANCE_CHROME ? { executablePath: process.env.HCC_ACCEPTANCE_CHROME } : {}) });
97
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
98
+ page.on('pageerror', error => evidence.pageErrors.push(error.message));
99
+ page.on('console', message => { if (message.type() === 'error') evidence.consoleErrors.push(message.text()); });
100
+ await page.goto('http://127.0.0.1:' + port + '/?token=' + env.HCC_WEB_TOKEN);
101
+ await page.waitForFunction(() => window.hccHandoff?.session?.type === 'native' && window.hccHandoff.canControl);
102
+ assert.equal(await page.locator('#nativePanel').isVisible(), true);
103
+ await page.locator('#nativeDraft').fill('Continue the same original task. Call hello_cc_scoped hcc_state once. Read handoff.json. Increase value by one, append "web" to phases, and save handoff.json. Do only these project-file operations. End with WEB_PHASE_OK.');
104
+ await page.locator('#nativeSend').click();
105
+ await page.waitForFunction(() => window.hccHandoff?.session?.type === 'native' && document.getElementById('nativeReceipts').textContent.includes('s_'));
106
+ let webDelivery;
107
+ for (let i = 0; i < 60; i++) { const deliveries = await api('GET', '/deliveries?peer=live-worker'); webDelivery = deliveries.find(row => row.submission_id.startsWith('s_')); if (webDelivery) break; await delay(100); }
108
+ assert.ok(webDelivery); const afterWeb = await complete(webDelivery.submission_id);
109
+ assert.deepEqual(output(), { value: 43, phases: ['local','web'] }); assert.equal(afterWeb.snapshot.sessionId, identity.sessionId); assert.equal(child.pid, identity.pid);
110
+ const screenshot = path.join(directory, 'live-web-task.png'); await page.screenshot({ path: screenshot }); evidence.screenshots.push(screenshot);
111
+ evidence.stages.push({ stage: 'web-same-executor-model-turn', passed: true, ...identity }); console.log('WEB_SAME_EXECUTOR_PHASE_OK');
112
+ await page.locator('#releaseControlBtn').click(); await page.waitForFunction(() => !window.hccHandoff.canControl);
113
+ await browser.close(); browser = null;
114
+ hcc('down'); runtimeStarted = false;
115
+ const back = await api('POST', '/send', { peer: 'live-worker', from: 'shell', submissionId: 'live_return_' + randomUUID(), body:
116
+ 'Continue the same task after Web released control. Call hello_cc_scoped hcc_state once. Increase handoff.json value by one, append "local-return" to phases and save. End with LOCAL_RETURN_PHASE_OK.' });
117
+ const final = await complete(back.submission_id); assert.deepEqual(output(), { value: 44, phases: ['local','web','local-return'] });
118
+ assert.equal(final.snapshot.sessionId, identity.sessionId); assert.equal(child.pid, identity.pid);
119
+ evidence.stages.push({ stage: 'local-return-after-web-shutdown', passed: true, ...identity }); evidence.output = output();
120
+ assert.equal(evidence.pageErrors.length, 0); assert.equal(evidence.consoleErrors.length, 0);
121
+ const mcpEvents = final.events.filter(event => event.payload?.type === 'item' && event.payload.item?.type === 'mcpToolCall');
122
+ evidence.mcpToolEvents = mcpEvents.length;
123
+ evidence.completed = true; console.log('LIVE_LOCAL_WEB_LOCAL_OK');
124
+ } catch (error) {
125
+ evidence.error = error.message;
126
+ process.exitCode = 1; console.error('LIVE_HANDOFF_FAILED: ' + error.message);
127
+ } finally {
128
+ await browser?.close();
129
+ if (runtimeStarted) { try { hcc('down'); } catch {} }
130
+ await service?.shutdown();
131
+ try { hcc('down'); } catch {}
132
+ spawnSync(tmux, ['-L', socket, 'kill-server'], { stdio: 'ignore' });
133
+ evidence.protectedConfigUnchanged = protectedHashes.every(([file, original]) => fs.existsSync(file) && hash(file) === original);
134
+ if (!evidence.protectedConfigUnchanged) { process.exitCode = 1; evidence.completed = false; }
135
+ 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 });
137
+ console.log(JSON.stringify({ evidence: path.join(directory, 'evidence.json'), completed: evidence.completed, protectedConfigUnchanged: evidence.protectedConfigUnchanged }));
138
+ }