@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.
- package/CHANGELOG.md +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +132 -80
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +105 -85
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +481 -219
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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 ' +
|
|
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
|
-
|
|
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
|
}
|