@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -23
- package/README.md +10 -1
- package/README.zh-CN.md +8 -1
- package/docs/commands.md +28 -4
- package/docs/commands.zh-CN.md +20 -4
- package/docs/dsh.md +2 -2
- package/docs/dsh.zh-CN.md +3 -3
- package/docs/native.md +84 -10
- package/docs/native.zh-CN.md +66 -5
- package/docs/web-handoff.zh-CN.md +11 -3
- package/lib/cli/app.mjs +42 -1
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +9 -4
- package/lib/cli/commands/hook.mjs +6 -1
- package/lib/cli/commands/install.mjs +48 -6
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/native.mjs +16 -9
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/db/connection.mjs +135 -10
- package/lib/db/schema.mjs +59 -0
- package/lib/discover.mjs +4 -4
- package/lib/guidance.mjs +3 -2
- 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 +12 -9
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/process/identity.mjs +15 -14
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/native/service.mjs +11 -5
- package/lib/runtime/native/store.mjs +70 -18
- 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 +148 -12
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +6 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +22 -11
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/ui/help.mjs +6 -4
- package/lib/web/codex-app-server.mjs +26 -5
- package/lib/web/codex-sessions.mjs +5 -3
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/external-sessions.mjs +54 -5
- package/lib/web/http-routes.mjs +13 -9
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +14 -7
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/project-contexts.mjs +2 -3
- package/lib/web/runtime-main.mjs +22 -5
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +115 -22
- package/lib/web/task-results.mjs +20 -17
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +2 -2
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +161 -32
- package/lib/web/ui-interactions.mjs +137 -4
- package/lib/web/ui-native.mjs +31 -13
- package/lib/web/ui-template.mjs +10 -3
- package/package.json +1 -1
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-installed-acceptance.mjs +413 -0
- package/scripts/regression.mjs +46 -29
- package/scripts/web-mcp-form-acceptance.mjs +119 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-interaction-acceptance.mjs +77 -10
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Opt-in installed Codex + real model + browser form acceptance.
|
|
2
|
+
// The MCP test server sends real elicitation/create requests; no App Server
|
|
3
|
+
// requests, model outputs or adapter responses are injected.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
+
import { spawnSync, execFile } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
14
|
+
import { initSchema } from '../lib/db/schema.mjs';
|
|
15
|
+
import { createEventHelpers } from '../lib/db/events.mjs';
|
|
16
|
+
import { createPeerHelpers } from '../lib/core/peers/peer-helpers.mjs';
|
|
17
|
+
import { createPeerBindingStore } from '../lib/db/stores/peers.mjs';
|
|
18
|
+
import { createMessageStore } from '../lib/core/coordination/messages.mjs';
|
|
19
|
+
import { inspectProcessIdentity } from '../lib/process/identity.mjs';
|
|
20
|
+
import { createCodexAdapter } from '../lib/integrations/native/codex.mjs';
|
|
21
|
+
import { JsonRpcProcess } from '../lib/integrations/native/jsonrpc.mjs';
|
|
22
|
+
import { startNativeService } from '../lib/runtime/native/service.mjs';
|
|
23
|
+
import { nativeRequest } from '../lib/runtime/native/client.mjs';
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const option = (name, fallback) => args.includes(name) ? args[args.indexOf(name) + 1] : fallback;
|
|
27
|
+
if (!args.includes('--run-live')) {
|
|
28
|
+
console.log('Usage: node scripts/web-mcp-form-live-acceptance.mjs --run-live [--codex-bin PATH] [--output FILE]');
|
|
29
|
+
console.log('Set HCC_ACCEPTANCE_PLAYWRIGHT and optionally HCC_ACCEPTANCE_CHROME / HCC_ACCEPTANCE_TMUX.');
|
|
30
|
+
console.log('Uses the existing Codex model/account in a disposable home. Real inference consumes provider quota.');
|
|
31
|
+
console.log('Tests local pending form -> Web typed response -> same native task -> local return, and Web-owned App Server forms.');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
const repo = fileURLToPath(new URL('..', import.meta.url));
|
|
35
|
+
const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
36
|
+
function sourceHashes() {
|
|
37
|
+
const result = {};
|
|
38
|
+
function walk(folder) {
|
|
39
|
+
for (const entry of fs.readdirSync(path.join(repo, folder), { withFileTypes: true })) {
|
|
40
|
+
const file = path.join(folder, entry.name);
|
|
41
|
+
if (entry.isDirectory()) walk(file);
|
|
42
|
+
else if (entry.isFile()) result[file] = digest(path.join(repo, file));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const folder of ['bin', 'lib', 'scripts', 'test']) walk(folder);
|
|
46
|
+
for (const file of ['package.json', 'package-lock.json']) result[file] = digest(path.join(repo, file));
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-live-mcp-form-')));
|
|
50
|
+
fs.chmodSync(directory, 0o700);
|
|
51
|
+
const root = path.join(directory, 'project'), home = path.join(directory, 'codex-home'), bin = path.join(directory, 'bin'), userHome = path.join(directory, 'home');
|
|
52
|
+
for (const folder of [root, home, bin, userHome, path.join(root, '.hello-cc')]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
53
|
+
const output = path.resolve(option('--output', path.join(directory, 'evidence.json')));
|
|
54
|
+
assert.ok(!fs.existsSync(output), 'Use a new output path; existing receipts are preserved');
|
|
55
|
+
const mcpLog = path.join(directory, 'synthetic-mcp-rpc.jsonl');
|
|
56
|
+
fs.writeFileSync(mcpLog, '', { mode: 0o600 });
|
|
57
|
+
const originalHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
58
|
+
const gitIndexResult = spawnSync('git', ['rev-parse', '--git-path', 'index'], { cwd: repo, encoding: 'utf8' });
|
|
59
|
+
const gitIndex = gitIndexResult.status === 0 ? path.resolve(repo, gitIndexResult.stdout.trim()) : null;
|
|
60
|
+
const originalUserHome = process.env.HOME || os.homedir();
|
|
61
|
+
const protectedFiles = [path.join(originalHome, 'config.toml'), path.join(originalHome, 'auth.json'), gitIndex,
|
|
62
|
+
...['.zshrc', '.bashrc', '.bash_profile', '.profile'].map(name => path.join(originalUserHome, name))].filter(Boolean);
|
|
63
|
+
const protectedHashes = protectedFiles.map(file => [file, fs.existsSync(file) ? digest(file) : null]);
|
|
64
|
+
const evidence = { startedAt: new Date().toISOString(), node: process.version, directory, sourceRoot: repo,
|
|
65
|
+
sourceFiles: sourceHashes(), modelInferenceCalled: false, requestInjection: false,
|
|
66
|
+
providerMode: 'installed Codex; real model calls a disposable stdio MCP server; production HCC runtime and browser',
|
|
67
|
+
browser: 'Isolated installed Chrome and Playwright; browser-extension connectivity is not exercised', checks: [], screenshots: [],
|
|
68
|
+
pageErrors: [], consoleErrors: [], completed: false };
|
|
69
|
+
const codexBin = option('--codex-bin', '/opt/homebrew/bin/codex');
|
|
70
|
+
const tmux = process.env.HCC_ACCEPTANCE_TMUX || '/opt/homebrew/bin/tmux';
|
|
71
|
+
const socket = 'hcc-live-mcp-' + randomUUID();
|
|
72
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
73
|
+
const env = { ...process.env, PATH: bin + ':' + path.dirname(process.execPath) + ':' + process.env.PATH,
|
|
74
|
+
HOME: userHome, CODEX_HOME: home, HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1', HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID() };
|
|
75
|
+
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];
|
|
76
|
+
const ctx = { root, dbPath: path.join(root, '.hello-cc', 'mesh.db') };
|
|
77
|
+
const connect = () => { const db = new DatabaseSync(ctx.dbPath); db.exec('PRAGMA busy_timeout=5000'); initSchema(db); return db; };
|
|
78
|
+
const events = createEventHelpers(), bindings = createPeerBindingStore(events);
|
|
79
|
+
const peers = createPeerHelpers({ ...events, now: () => Math.floor(Date.now() / 1000) });
|
|
80
|
+
const deps = { ...events, ...bindings, ...peers, ...createMessageStore(events), connect, detectBranch: () => '',
|
|
81
|
+
liveProcessIdentity: pid => inspectProcessIdentity(pid).identity };
|
|
82
|
+
const api = (method, route, body) => nativeRequest(ctx, method, route, body, { timeoutMs: 30000 });
|
|
83
|
+
const records = () => fs.readFileSync(mcpLog, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
|
|
84
|
+
async function hcc(...params) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
execFile(process.execPath, [path.join(repo, 'bin/hcc.mjs'), '--root', root, ...params],
|
|
87
|
+
{ cwd: root, env, encoding: 'utf8', timeout: 30000 }, (error, stdout, stderr) => {
|
|
88
|
+
if (!error) { resolve(stdout); return; }
|
|
89
|
+
evidence.cliFailures ||= [];
|
|
90
|
+
evidence.cliFailures.push({ command: params[0], code: error.code, signal: error.signal,
|
|
91
|
+
stderr: String(stderr || '').replaceAll(env.HCC_WEB_TOKEN, '[isolated-token]').slice(-3000) });
|
|
92
|
+
reject(new Error('Isolated HCC CLI failed: ' + params[0]));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function check(name, details = {}) { evidence.checks.push({ name, passed: true, ...details }); console.log('PASS ' + name); }
|
|
98
|
+
async function until(predicate, label, timeout = 180000) {
|
|
99
|
+
const deadline = Date.now() + timeout; let notice = Date.now() + 20000;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
const result = await predicate(); if (result) return result;
|
|
102
|
+
if (Date.now() >= notice) { console.log('WAIT ' + label); notice += 20000; }
|
|
103
|
+
await delay(200);
|
|
104
|
+
}
|
|
105
|
+
throw new Error('Timed out: ' + label);
|
|
106
|
+
}
|
|
107
|
+
function failedNativeState(state) {
|
|
108
|
+
evidence.nativeFailure = { status: state.snapshot?.status, turnId: state.snapshot?.turnId,
|
|
109
|
+
deliveries: state.deliveries, errors: state.events.filter(entry => entry.payload?.type === 'error').map(entry => entry.payload),
|
|
110
|
+
messages: state.events.filter(entry => ['message', 'completed'].includes(entry.payload?.type)).map(entry => ({ type: entry.payload.type, status: entry.payload.status, text: entry.payload.text })) };
|
|
111
|
+
}
|
|
112
|
+
async function completed(submissionId) {
|
|
113
|
+
return until(async () => {
|
|
114
|
+
const state = await api('GET', '/workers/live-mcp/state');
|
|
115
|
+
const delivery = state.deliveries.find(row => row.submission_id === submissionId);
|
|
116
|
+
if (['failed', 'uncertain', 'interrupted'].includes(delivery?.state)) { failedNativeState(state); throw new Error('Native delivery ended in ' + delivery.state); }
|
|
117
|
+
const unexpected = state.snapshot.pendingApprovals?.find(request => request.kind !== 'mcp');
|
|
118
|
+
if (unexpected) throw new Error('Unexpected interaction: ' + unexpected.method);
|
|
119
|
+
return delivery?.state === 'completed' ? state : null;
|
|
120
|
+
}, 'native model completes accepted form');
|
|
121
|
+
}
|
|
122
|
+
const expected = {
|
|
123
|
+
native: { project: 'native-mcp-验收', count: 2, enabled: false, region: 'us', features: ['logs', 'tests'] },
|
|
124
|
+
'web-owned': { project: 'web-owned-mcp-验收', count: 3, enabled: false, region: 'eu', features: ['tests'] }
|
|
125
|
+
};
|
|
126
|
+
const prompt = phase => 'Call the hcc_form_acceptance MCP tool collect_project_settings with phase "' + phase + '" exactly once. '
|
|
127
|
+
+ 'Wait for the human form response. If accepted, use your project file tool to write ' + phase + '-answer.json in the current project directory. '
|
|
128
|
+
+ 'The file must contain exactly the JSON content object returned by the tool, without action, phase or extra keys. '
|
|
129
|
+
+ 'Do not guess the human values. You may use apply_patch or a local shell command only to write this one project JSON file; do not perform other operations. Finish with FORM_' + (phase === 'native' ? 'NATIVE' : 'WEB') + '_COMPLETE.';
|
|
130
|
+
function response(phase) {
|
|
131
|
+
const rows = records().filter(row => row.event === 'form-response' && row.phase === phase);
|
|
132
|
+
assert.equal(rows.length, 1, 'one real MCP response for ' + phase);
|
|
133
|
+
assert.deepEqual(rows[0].response, { action: 'accept', content: expected[phase] });
|
|
134
|
+
const result = JSON.parse(fs.readFileSync(path.join(root, phase + '-answer.json'), 'utf8'));
|
|
135
|
+
assert.deepEqual(result, expected[phase]);
|
|
136
|
+
assert.ok(fs.statSync(path.join(root, phase + '-answer.json')).mtimeMs >= Date.parse(rows[0].at), 'model writes after accepted form');
|
|
137
|
+
return { elicitationId: rows[0].elicitationId, content: result };
|
|
138
|
+
}
|
|
139
|
+
const fieldTitles = { project: 'Project name / 项目名', count: 'Run count / 次数',
|
|
140
|
+
enabled: 'Enable extra operation / 开启额外操作', region: 'Region / 地区', features: 'Features / 功能', note: 'Optional note / 可选备注' };
|
|
141
|
+
const field = (page, selector, name) => page.locator(selector + ' .hcc-mcp-field').filter({ has: page.locator('legend').filter({ hasText: fieldTitles[name] }) });
|
|
142
|
+
async function fill(page, selector, phase) {
|
|
143
|
+
const values = expected[phase];
|
|
144
|
+
await field(page, selector, 'project').locator('[data-mcp-field]').fill(values.project);
|
|
145
|
+
await field(page, selector, 'count').locator('[data-mcp-field]').fill(String(values.count));
|
|
146
|
+
await field(page, selector, 'enabled').locator('[data-mcp-field]').selectOption('1');
|
|
147
|
+
await field(page, selector, 'region').locator('[data-mcp-field]').selectOption(values.region === 'eu' ? '0' : '1');
|
|
148
|
+
for (const [index, value] of ['logs', 'tests'].entries()) await field(page, selector, 'features').locator('[data-mcp-option="' + index + '"]').setChecked(values.features.includes(value));
|
|
149
|
+
assert.equal(await field(page, selector, 'note').locator('[data-mcp-use]').isChecked(), false);
|
|
150
|
+
}
|
|
151
|
+
async function awaitForm(page, selector, phase) {
|
|
152
|
+
let confirmations = 0;
|
|
153
|
+
await until(async () => {
|
|
154
|
+
if (await field(page, selector, 'project').count()) return true;
|
|
155
|
+
const button = page.locator(selector + ' button[data-decision="accept"]');
|
|
156
|
+
if (await button.count() && await button.isEnabled()) {
|
|
157
|
+
assert.equal(await page.locator(selector + ' [data-mcp-field]').count(), 0, 'only an empty MCP confirmation may precede the form');
|
|
158
|
+
assert.ok((await page.locator(selector).innerText()).includes('hcc_form_acceptance'), 'confirmation belongs to the isolated MCP server');
|
|
159
|
+
assert.equal(confirmations++, 0, 'at most one tool confirmation per phase');
|
|
160
|
+
await button.click();
|
|
161
|
+
check(phase + ' Web explicitly accepts the real MCP tool confirmation before the field form');
|
|
162
|
+
}
|
|
163
|
+
if (records().some(row => row.event === 'form-timeout')) throw new Error('Real MCP server timed out');
|
|
164
|
+
return false;
|
|
165
|
+
}, phase + ' installed model calls the real MCP field form');
|
|
166
|
+
assert.ok(records().find(row => row.event === 'form-requested' && row.phase === phase));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function shot(page, name) {
|
|
170
|
+
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
171
|
+
const file = path.join(directory, name + '.png');
|
|
172
|
+
await page.screenshot({ path: file, animations: 'disabled' }); evidence.screenshots.push(file);
|
|
173
|
+
}
|
|
174
|
+
async function rendered(page, type) {
|
|
175
|
+
await page.waitForFunction(type => window.hccHandoff?.session?.type === type && window.hccHandoff.canControl, type, { timeout: 20000 });
|
|
176
|
+
assert.ok((await page.title()).includes('hello-cc'));
|
|
177
|
+
const panel = type === 'native' ? '#nativePanel' : '#codexPanel';
|
|
178
|
+
assert.equal(await page.locator(panel).isVisible(), true);
|
|
179
|
+
assert.ok((await page.locator(panel).innerText()).trim().length > 50);
|
|
180
|
+
assert.equal(await page.locator('vite-error-overlay, nextjs-portal, #webpack-dev-server-client-overlay').count(), 0);
|
|
181
|
+
}
|
|
182
|
+
let service, browser, runtimeStarted = false, nativeChild, page, base;
|
|
183
|
+
try {
|
|
184
|
+
assert.ok(fs.existsSync(tmux), 'tmux must be installed');
|
|
185
|
+
assert.ok(fs.existsSync(codexBin), 'Codex must be installed');
|
|
186
|
+
assert.ok(process.env.HCC_ACCEPTANCE_PLAYWRIGHT, 'Set HCC_ACCEPTANCE_PLAYWRIGHT');
|
|
187
|
+
const source = fs.existsSync(path.join(originalHome, 'config.toml')) ? fs.readFileSync(path.join(originalHome, 'config.toml'), 'utf8') : '';
|
|
188
|
+
let section = '';
|
|
189
|
+
let config = source.split('\n').filter(line => {
|
|
190
|
+
const header = line.match(/^\s*\[\[?([^\]]+)\]\]?/); if (header) section = header[1].split('.')[0].replaceAll('"', '').replaceAll("'", '');
|
|
191
|
+
return section ? section === 'model_providers' : /^\s*(?:model(?:_provider|_reasoning_effort|_reasoning_summary|_verbosity|_catalog_json)?|cli_auth_credentials_store)\s*=/.test(line);
|
|
192
|
+
}).join('\n');
|
|
193
|
+
evidence.model = JSON.parse(source.match(/^model\s*=\s*(".*")\s*$/m)?.[1] || 'null');
|
|
194
|
+
// Managed providers may use a read-only external auth helper whose bootstrap
|
|
195
|
+
// resolves the user's installed runtime via HOME. Keep only that helper on
|
|
196
|
+
// its original home; HCC, Codex and their writes remain in disposable homes.
|
|
197
|
+
const selectedProvider = JSON.parse(source.match(/^model_provider\s*=\s*(".*")\s*$/m)?.[1] || '"openai"');
|
|
198
|
+
const authHeadings = ['model_providers.' + selectedProvider + '.auth', 'model_providers."' + selectedProvider + '".auth'];
|
|
199
|
+
let authHelperCount = 0;
|
|
200
|
+
config = config.split(/(?=^\[)/m).map(block => {
|
|
201
|
+
const heading = /^\[([^\]]+)\]/.exec(block)?.[1];
|
|
202
|
+
if (!authHeadings.includes(heading)) return block;
|
|
203
|
+
const command = JSON.parse(block.match(/^\s*command\s*=\s*(.+)$/m)?.[1] || 'null');
|
|
204
|
+
const helperArgs = JSON.parse(block.match(/^\s*args\s*=\s*(.+)$/m)?.[1] || '[]');
|
|
205
|
+
assert.ok(typeof command === 'string' && Array.isArray(helperArgs) && helperArgs.every(arg => typeof arg === 'string'), 'Cannot isolate the configured auth helper');
|
|
206
|
+
const wrappedArgs = ['HOME=' + originalUserHome, command, ...helperArgs];
|
|
207
|
+
let result = block.replace(/^\s*command\s*=.*$/m, 'command = "/usr/bin/env"');
|
|
208
|
+
if (/^\s*args\s*=/m.test(result)) result = result.replace(/^\s*args\s*=.*$/m, 'args = ' + JSON.stringify(wrappedArgs));
|
|
209
|
+
else result += '\nargs = ' + JSON.stringify(wrappedArgs) + '\n';
|
|
210
|
+
authHelperCount++; return result;
|
|
211
|
+
}).join('');
|
|
212
|
+
evidence.authHelperIsolation = { configured: authHelperCount === 1, originalHomeScope: 'selected credential helper only; Codex and HCC homes are disposable' };
|
|
213
|
+
|
|
214
|
+
const serverConfig = '\n[mcp_servers.hcc_form_acceptance]\ncommand = ' + JSON.stringify(process.execPath)
|
|
215
|
+
+ '\nargs = ' + JSON.stringify([path.join(repo, 'scripts/mcp-form-live-fixture.mjs'), '--stdio', '--log', mcpLog])
|
|
216
|
+
+ '\nstartup_timeout_sec = 30\ntool_timeout_sec = 240\n';
|
|
217
|
+
fs.writeFileSync(path.join(home, 'config.toml'), config + '\n' + serverConfig, { mode: 0o600 });
|
|
218
|
+
if (fs.existsSync(path.join(originalHome, 'auth.json'))) {
|
|
219
|
+
fs.copyFileSync(path.join(originalHome, 'auth.json'), path.join(home, 'auth.json')); fs.chmodSync(path.join(home, 'auth.json'), 0o600);
|
|
220
|
+
}
|
|
221
|
+
fs.writeFileSync(path.join(bin, 'tmux'), '#!/bin/sh\nexec ' + quote(tmux) + ' -L ' + quote(socket) + ' "$@"\n', { mode: 0o700 });
|
|
222
|
+
fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\nexec ' + quote(codexBin) + ' "$@"\n', { mode: 0o700 });
|
|
223
|
+
const version = spawnSync(codexBin, ['--version'], { env, encoding: 'utf8', timeout: 5000 });
|
|
224
|
+
assert.equal(version.status, 0); evidence.codexVersion = version.stdout.trim();
|
|
225
|
+
await hcc('up', '--no-discover', '--no-guidance');
|
|
226
|
+
service = await startNativeService(ctx, deps, { adapterFactory: (provider, options) => {
|
|
227
|
+
assert.equal(provider, 'codex');
|
|
228
|
+
return createCodexAdapter({ ...options, binary: codexBin,
|
|
229
|
+
env: { ...options.env, ...env, HCC_PEER: options.env.HCC_PEER, HCC_ROOT: root, HCC_DB: ctx.dbPath, HCC_NATIVE_OWNER: options.executorId },
|
|
230
|
+
rpcFactory: config => { const rpc = new JsonRpcProcess(config), start = rpc.start.bind(rpc);
|
|
231
|
+
rpc.start = async () => { await start(); nativeChild = rpc.child; }; return rpc; } });
|
|
232
|
+
} });
|
|
233
|
+
const worker = await api('POST', '/workers', { peer: 'live-mcp', provider: 'codex' });
|
|
234
|
+
const originalIdentity = { sessionId: worker.sessionId, executorId: worker.executorId, pid: nativeChild.pid };
|
|
235
|
+
evidence.modelInferenceCalled = true;
|
|
236
|
+
const local = await api('POST', '/send', { peer: 'live-mcp', from: 'shell', submissionId: 'mcp_local_' + randomUUID(), body: prompt('native') });
|
|
237
|
+
const pending = await until(async () => {
|
|
238
|
+
const state = await api('GET', '/workers/live-mcp/state');
|
|
239
|
+
const request = state.snapshot.pendingApprovals?.find(request => request.kind === 'mcp');
|
|
240
|
+
const delivery = state.deliveries.find(row => row.submission_id === local.submission_id);
|
|
241
|
+
if (['failed', 'uncertain', 'completed'].includes(delivery?.state)) { failedNativeState(state); throw new Error('Native turn ended before real MCP form: ' + delivery.state); }
|
|
242
|
+
if (state.snapshot.pendingApprovals?.some(request => request.kind !== 'mcp')) throw new Error('Unexpected native interaction before MCP form');
|
|
243
|
+
return request ? { request, state } : null;
|
|
244
|
+
}, 'installed model calls MCP form before Web starts');
|
|
245
|
+
assert.equal(records().some(row => row.event === 'form-response'), false);
|
|
246
|
+
evidence.nativeInitialRequest = { method: pending.request.method, mode: pending.request.params.mode, serverName: pending.request.params.serverName, fieldCount: Object.keys(pending.request.params.requestedSchema?.properties || {}).length };
|
|
247
|
+
assert.equal(pending.request.sessionId, originalIdentity.sessionId);
|
|
248
|
+
assert.equal(pending.request.params.mode, 'form');
|
|
249
|
+
check('real native model reaches MCP interaction and remains pending before Web starts', { ...originalIdentity, turnId: pending.request.turnId, requestId: pending.request.requestId });
|
|
250
|
+
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)); }); });
|
|
251
|
+
base = 'http://127.0.0.1:' + port; evidence.baseUrl = base;
|
|
252
|
+
await hcc('web', '--local', '--port', String(port), '--no-discover', '--no-guidance'); runtimeStarted = true;
|
|
253
|
+
const { chromium } = await import(process.env.HCC_ACCEPTANCE_PLAYWRIGHT);
|
|
254
|
+
browser = await chromium.launch({ headless: true, ...(process.env.HCC_ACCEPTANCE_CHROME ? { executablePath: process.env.HCC_ACCEPTANCE_CHROME } : {}) });
|
|
255
|
+
page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
|
256
|
+
page.on('pageerror', error => evidence.pageErrors.push(error.message));
|
|
257
|
+
page.on('console', message => { if (['warning', 'error'].includes(message.type())) evidence.consoleErrors.push(message.text()); });
|
|
258
|
+
await page.goto(base + '/?token=' + env.HCC_WEB_TOKEN); await rendered(page, 'native');
|
|
259
|
+
await awaitForm(page, '#nativeApprovals', 'native');
|
|
260
|
+
check('Web opens the original native task and renders the real MCP form');
|
|
261
|
+
await fill(page, '#nativeApprovals', 'native');
|
|
262
|
+
await field(page, '#nativeApprovals', 'count').locator('[data-mcp-field]').fill('9');
|
|
263
|
+
await page.locator('#nativeApprovals button[data-decision="accept"]').click();
|
|
264
|
+
await page.waitForFunction(() => document.getElementById('nativeNotice').textContent.includes('count'));
|
|
265
|
+
assert.equal(records().some(row => row.event === 'form-response'), false);
|
|
266
|
+
assert.equal((await api('GET', '/workers/live-mcp/state')).snapshot.pendingApprovals.length, 1);
|
|
267
|
+
check('invalid live form answer is blocked and the actual model request stays pending');
|
|
268
|
+
await field(page, '#nativeApprovals', 'count').locator('[data-mcp-field]').fill('2');
|
|
269
|
+
await page.locator('#nativeRead').click();
|
|
270
|
+
await until(async () => await field(page, '#nativeApprovals', 'project').locator('[data-mcp-field]').inputValue() === expected.native.project, 'native draft retained');
|
|
271
|
+
await page.evaluate(() => { document.querySelector('.native-scroll').scrollTop = 0; });
|
|
272
|
+
await shot(page, 'native-real-mcp-form-desktop');
|
|
273
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
274
|
+
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
|
|
275
|
+
await shot(page, 'native-real-mcp-form-mobile');
|
|
276
|
+
await page.locator('#nativeApprovals button[data-decision="accept"]').click();
|
|
277
|
+
const afterNative = await completed(local.submission_id);
|
|
278
|
+
evidence.nativeCompletedMessages = afterNative.events.filter(entry => ['message', 'completed'].includes(entry.payload?.type)).map(entry => ({ type: entry.payload.type, text: entry.payload.text, status: entry.payload.status }));
|
|
279
|
+
assert.equal(afterNative.snapshot.sessionId, originalIdentity.sessionId);
|
|
280
|
+
assert.equal(afterNative.snapshot.executorId, originalIdentity.executorId); assert.equal(nativeChild.pid, originalIdentity.pid);
|
|
281
|
+
assert.equal(nativeChild.exitCode, null);
|
|
282
|
+
assert.ok(afterNative.events.some(entry => entry.payload?.type === 'completed' && entry.payload.submissionId === local.submission_id && entry.payload.text?.includes('FORM_NATIVE_COMPLETE')));
|
|
283
|
+
check('mobile Web submits typed values to the live native model and the same task completes', { ...originalIdentity, ...response('native') });
|
|
284
|
+
await page.locator('#nativeRead').click();
|
|
285
|
+
await page.waitForFunction(status => document.getElementById('nativeStatus').textContent.endsWith(' · ' + window.hccHandoff.tr('native.state.' + status, status))
|
|
286
|
+
&& document.getElementById('nativeEvents').textContent.includes('FORM_NATIVE_COMPLETE')
|
|
287
|
+
&& document.querySelectorAll('#nativeApprovals [data-mcp-field]').length === 0, afterNative.snapshot.status, { timeout: 20000 });
|
|
288
|
+
assert.equal(await page.locator('#nativeNotice').innerText(), '', 'successful retry removes the earlier invalid-value notice');
|
|
289
|
+
await page.evaluate(() => { const scroll = document.querySelector('.native-scroll'); scroll.scrollTop = scroll.scrollHeight; });
|
|
290
|
+
evidence.nativeCompletedUi = { status: await page.locator('#nativeStatus').innerText(),
|
|
291
|
+
markerVisible: (await page.locator('#nativeEvents').innerText()).includes('FORM_NATIVE_COMPLETE'), noticeCleared: true };
|
|
292
|
+
await shot(page, 'native-real-mcp-completed-mobile');
|
|
293
|
+
await page.setViewportSize({ width: 1440, height: 1000 });
|
|
294
|
+
const web = await page.evaluate(async root => window.hccHandoff.api('/api/sessions?root=' + encodeURIComponent(root), {
|
|
295
|
+
method: 'POST', body: JSON.stringify({ kind: 'codex', transport: 'app-server' })
|
|
296
|
+
}), root);
|
|
297
|
+
const webId = web.session.id;
|
|
298
|
+
await page.evaluate(async id => { await window.hccHandoff.refreshSessions(); window.hccHandoff.openManaged(id); }, webId);
|
|
299
|
+
await rendered(page, 'app-server');
|
|
300
|
+
await page.locator('#codexDraft').fill(prompt('web-owned')); await page.locator('#codexSend').click();
|
|
301
|
+
await awaitForm(page, '#codexApprovals', 'web-owned');
|
|
302
|
+
const webState = await page.evaluate(async ({ id, root }) => (await window.hccHandoff.api('/api/sessions/' + encodeURIComponent(id) + '/codex/state?root=' + encodeURIComponent(root))).state, { id: webId, root });
|
|
303
|
+
const webRequest = webState.pendingApprovals.find(request => request.method === 'mcpServer/elicitation/request' && Object.keys(request.params.requestedSchema?.properties || {}).length);
|
|
304
|
+
assert.ok(webRequest);
|
|
305
|
+
const webIdentity = { managedSessionId: webId, threadId: webRequest.threadId, turnId: webRequest.turnId,
|
|
306
|
+
executorId: webState.executorId, pid: webState.pid, requestId: webRequest.requestId };
|
|
307
|
+
await fill(page, '#codexApprovals', 'web-owned'); await shot(page, 'web-owned-real-mcp-form-desktop');
|
|
308
|
+
await page.locator('#codexApprovals button[data-decision="accept"]').click();
|
|
309
|
+
const afterWeb = await until(async () => {
|
|
310
|
+
const state = await page.evaluate(async ({ id, root }) => (await window.hccHandoff.api('/api/sessions/' + encodeURIComponent(id) + '/codex/state?root=' + encodeURIComponent(root))).state, { id: webId, root });
|
|
311
|
+
const turn = state.threads.find(thread => thread.id === webIdentity.threadId)?.turns.find(turn => turn.id === webIdentity.turnId);
|
|
312
|
+
if (['failed', 'interrupted'].includes(turn?.status)) throw new Error('Web-owned turn ended in ' + turn.status);
|
|
313
|
+
return turn?.status === 'completed' ? state : null;
|
|
314
|
+
}, 'Web-owned provider turn actually completes');
|
|
315
|
+
const completedTurn = afterWeb.threads.find(thread => thread.id === webIdentity.threadId).turns.find(turn => turn.id === webIdentity.turnId);
|
|
316
|
+
assert.ok(completedTurn.items.some(item => item.type === 'agentMessage' && item.text?.includes('FORM_WEB_COMPLETE')), 'completion marker belongs to an assistant message');
|
|
317
|
+
assert.equal(afterWeb.executorId, webIdentity.executorId); assert.equal(afterWeb.pid, webIdentity.pid);
|
|
318
|
+
assert.equal(afterWeb.threadId, webIdentity.threadId); assert.equal(afterWeb.turnId, null);
|
|
319
|
+
assert.equal(afterWeb.pendingApprovals.length, 0);
|
|
320
|
+
evidence.webCompletedTurn = { ...webIdentity, status: completedTurn.status,
|
|
321
|
+
assistantMessages: completedTurn.items.filter(item => item.type === 'agentMessage').map(item => item.text) };
|
|
322
|
+
check('Web-owned App Server returns the real typed answer, model writes the result and provider confirms turn completion', { ...webIdentity, ...response('web-owned') });
|
|
323
|
+
await page.locator('#codexRecover').click();
|
|
324
|
+
if (await page.locator('#codexJump').isVisible()) await page.locator('#codexJump').click();
|
|
325
|
+
await shot(page, 'web-owned-real-mcp-completed-desktop');
|
|
326
|
+
await page.evaluate(id => window.hccHandoff.openManaged(id), 'live-mcp'); await rendered(page, 'native');
|
|
327
|
+
await page.locator('#releaseControlBtn').click(); await page.waitForFunction(() => !window.hccHandoff.canControl);
|
|
328
|
+
await browser.close(); browser = null;
|
|
329
|
+
await hcc('down'); runtimeStarted = false;
|
|
330
|
+
const back = await api('POST', '/send', { peer: 'live-mcp', from: 'shell', submissionId: 'mcp_return_' + randomUUID(), body:
|
|
331
|
+
'Continue this same task after Web released control. Read native-answer.json and use your project file tool to write local-return.json with exactly {"countPlusOne": count + 1} using the saved count. You may use apply_patch or a local shell command only to read native-answer.json and write local-return.json in this project. Do not perform other operations. End with FORM_LOCAL_RETURN_COMPLETE.' });
|
|
332
|
+
const afterReturn = await completed(back.submission_id);
|
|
333
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'local-return.json'), 'utf8')), { countPlusOne: expected.native.count + 1 });
|
|
334
|
+
assert.equal(afterReturn.snapshot.sessionId, originalIdentity.sessionId);
|
|
335
|
+
assert.equal(afterReturn.snapshot.executorId, originalIdentity.executorId); assert.equal(nativeChild.pid, originalIdentity.pid);
|
|
336
|
+
assert.equal(nativeChild.exitCode, null);
|
|
337
|
+
assert.ok(afterReturn.events.some(entry => entry.payload?.type === 'completed' && entry.payload.submissionId === back.submission_id && entry.payload.text?.includes('FORM_LOCAL_RETURN_COMPLETE')));
|
|
338
|
+
check('local return after Web shutdown continues the same native executor and uses its accepted answer', originalIdentity);
|
|
339
|
+
const initialized = records().filter(row => row.event === 'initialize');
|
|
340
|
+
assert.ok(initialized.length >= 2); evidence.mcpClients = initialized;
|
|
341
|
+
evidence.mcpResponses = records().filter(row => row.event === 'form-response');
|
|
342
|
+
assert.deepEqual(evidence.pageErrors, []); assert.deepEqual(evidence.consoleErrors, []);
|
|
343
|
+
check('desktop/mobile page identity, nonblank panels, no framework overlay and no browser warnings/errors');
|
|
344
|
+
evidence.completed = true;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
evidence.error = error.message; evidence.errorStack = error.stack; evidence.completed = false; process.exitCode = 1;
|
|
347
|
+
console.error('LIVE_MCP_FORM_FAILED: ' + error.message);
|
|
348
|
+
if (page && !page.isClosed()) try { await shot(page, 'failure'); } catch {}
|
|
349
|
+
} finally {
|
|
350
|
+
try { await browser?.close(); } catch (error) { evidence.browserCleanupError = error.message; }
|
|
351
|
+
if (runtimeStarted) try { await hcc('down'); } catch (error) { evidence.runtimeCleanupError = error.message; }
|
|
352
|
+
try { await service?.shutdown(); } catch (error) { evidence.serviceCleanupError = error.message; }
|
|
353
|
+
spawnSync(tmux, ['-L', socket, 'kill-server'], { stdio: 'ignore', timeout: 5000 });
|
|
354
|
+
evidence.protectedStateUnchanged = protectedHashes.every(([file, original]) => (fs.existsSync(file) ? digest(file) : null) === original);
|
|
355
|
+
const after = sourceHashes();
|
|
356
|
+
evidence.sourceChangesDuringValidation = [...new Set([...Object.keys(evidence.sourceFiles), ...Object.keys(after)])].filter(file => after[file] !== evidence.sourceFiles[file]);
|
|
357
|
+
evidence.fixturePids = [...new Set(records().map(row => row.pid))];
|
|
358
|
+
evidence.fixtureProcessesStopped = await until(() => evidence.fixturePids.every(pid => inspectProcessIdentity(pid).state === 'dead'), 'private MCP processes stop', 10000).catch(() => false);
|
|
359
|
+
evidence.nativeProcessStopped = !nativeChild || nativeChild.exitCode !== null || nativeChild.signalCode !== null;
|
|
360
|
+
for (const folder of [root, home, bin, userHome]) fs.rmSync(folder, { recursive: true, force: true });
|
|
361
|
+
evidence.privateHomesRemoved = !fs.existsSync(home) && !fs.existsSync(root) && !fs.existsSync(userHome);
|
|
362
|
+
evidence.finishedAt = new Date().toISOString();
|
|
363
|
+
if (!evidence.protectedStateUnchanged || evidence.sourceChangesDuringValidation.length || !evidence.fixtureProcessesStopped || !evidence.nativeProcessStopped ||
|
|
364
|
+
evidence.browserCleanupError || evidence.runtimeCleanupError || evidence.serviceCleanupError) { evidence.completed = false; process.exitCode = 1; }
|
|
365
|
+
fs.writeFileSync(output, JSON.stringify(evidence, null, 2), { mode: 0o600 });
|
|
366
|
+
console.log(JSON.stringify({ evidence: output, completed: evidence.completed, protectedStateUnchanged: evidence.protectedStateUnchanged,
|
|
367
|
+
sourceChangesDuringValidation: evidence.sourceChangesDuringValidation, fixtureProcessesStopped: evidence.fixtureProcessesStopped }));
|
|
368
|
+
}
|
|
@@ -19,28 +19,34 @@ 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 { resolveDshBinary, createDshEnvironment } from '../lib/integrations/dsh.mjs';
|
|
23
|
+
import { checkDshRuntime, DSH_CORDIS_VERSION } from '../lib/integrations/dsh-cordis.mjs';
|
|
22
24
|
|
|
23
25
|
const args = process.argv.slice(2);
|
|
24
26
|
const option = (name, fallback) => args.includes(name) ? args[args.indexOf(name) + 1] : fallback;
|
|
25
27
|
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]');
|
|
28
|
+
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
29
|
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.');
|
|
30
|
+
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
31
|
process.exit(0);
|
|
29
32
|
}
|
|
30
|
-
const providers = option('--provider', 'codex') === 'all' ? ['codex', 'claude'] : [option('--provider', 'codex')];
|
|
31
|
-
assert.ok(providers.every(value => ['codex', 'claude'].includes(value)), 'unknown provider');
|
|
33
|
+
const providers = option('--provider', 'codex') === 'all' ? ['codex', 'claude', 'dsh'] : [option('--provider', 'codex')];
|
|
34
|
+
assert.ok(providers.every(value => ['codex', 'claude', 'dsh'].includes(value)), 'unknown provider');
|
|
32
35
|
const repo = fileURLToPath(new URL('..', import.meta.url));
|
|
33
36
|
const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-live-web-interactions-')));
|
|
34
37
|
fs.chmodSync(directory, 0o700);
|
|
35
38
|
const root = path.join(directory, 'project'), bin = path.join(directory, 'bin');
|
|
36
39
|
const codexHome = path.join(directory, 'codex-home'), claudeHome = path.join(directory, 'claude-home');
|
|
40
|
+
const dshHome = path.join(directory, 'dsh-home');
|
|
37
41
|
const outside = path.join(directory, 'requested-write');
|
|
38
|
-
|
|
42
|
+
const dshAllowed = path.join(outside, 'dsh-allowed.txt'), dshDenied = path.join(directory, 'dsh-denied', 'must-not-exist.txt');
|
|
43
|
+
let dshBinary;
|
|
44
|
+
for (const folder of [root, bin, codexHome, claudeHome, dshHome, outside, path.join(root, '.hello-cc')]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
39
45
|
const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
40
46
|
const originalHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
41
47
|
const protectedFiles = ['config.toml', 'auth.json'].map(name => path.join(originalHome, name)).filter(file => fs.existsSync(file));
|
|
42
48
|
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))]));
|
|
49
|
+
const sourceHashes = () => Object.fromEntries(['lib/integrations/codex-interactions.mjs', 'lib/integrations/native/codex.mjs', 'lib/integrations/native/claude.mjs', 'lib/integrations/native/dsh-acp.mjs', 'lib/integrations/native/jsonrpc.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', 'lib/web/ui-template.mjs', 'lib/web/ui-workspace.mjs', 'lib/web/http.mjs', 'scripts/web-native-interaction-acceptance.mjs'].map(name => [name, digest(path.join(repo, name))]));
|
|
44
50
|
const evidence = { startedAt: new Date().toISOString(), directory, sourceFiles: sourceHashes(), providers: {}, checks: [], screenshots: [], pageErrors: [], consoleErrors: [], completed: false,
|
|
45
51
|
browser: 'Browser plugin not available; isolated installed Chrome and Playwright', modelInferenceCalled: false };
|
|
46
52
|
const output = path.resolve(option('--output', path.join(directory, 'evidence.json')));
|
|
@@ -92,10 +98,28 @@ try {
|
|
|
92
98
|
fs.symlinkSync(installed, path.join(scoped, 'claude-agent-sdk'), 'dir');
|
|
93
99
|
evidence.providers.claude = { sdkVersion: pkg.version, dependencyResolution: 'default loader: optional SDK installed in isolated worker project; no query injection' };
|
|
94
100
|
}
|
|
101
|
+
if (providers.includes('dsh')) {
|
|
102
|
+
assert.ok(process.env.DEEPSEEK_API_KEY, 'dsh live acceptance requires the existing DEEPSEEK_API_KEY');
|
|
103
|
+
dshBinary = resolveDshBinary({ dshBin: option('--dsh-bin') });
|
|
104
|
+
assert.ok(dshBinary, 'dsh public launcher must be installed'); checkDshRuntime(dshBinary);
|
|
105
|
+
const dshEnv = { ...createDshEnvironment(env), HOME: dshHome, DSH_HOME: path.join(dshHome, 'dsh'), DSH_TELEMETRY_DISABLED: '1' };
|
|
106
|
+
const initialized = spawnSync(dshBinary, ['--profile', 'acp', '--dump-config'], { cwd: root,
|
|
107
|
+
env: { ...dshEnv, DEEPSEEK_API_KEY: 'isolated-no-model-placeholder' }, encoding: 'utf8', timeout: 30000 });
|
|
108
|
+
assert.equal(initialized.status, 0, 'isolated ACP profile initialization failed (config output omitted)');
|
|
109
|
+
const policy = path.join(dshHome, 'acceptance-approval-policy.mjs');
|
|
110
|
+
// Exercise the official pre-execute policy and real model tool calls. Do
|
|
111
|
+
// not synthesize ACP requests or change the user's/default approval policy.
|
|
112
|
+
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 });
|
|
113
|
+
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 });
|
|
114
|
+
evidence.providers.dsh = { runtimeVersion: DSH_CORDIS_VERSION, publicLauncher: dshBinary,
|
|
115
|
+
approvalPolicy: 'official tools/pre-execute ask policy in private ACP profile; only the two acceptance-owned target files',
|
|
116
|
+
permissionTargets: [dshAllowed, dshDenied] };
|
|
117
|
+
}
|
|
95
118
|
hcc('up', '--no-discover', '--no-guidance');
|
|
96
119
|
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
|
-
|
|
120
|
+
env: { ...options.env, ...(provider === 'codex' ? { CODEX_HOME: codexHome } : provider === 'claude' ? { CLAUDE_CONFIG_DIR: claudeHome }
|
|
121
|
+
: { HOME: dshHome, DSH_HOME: path.join(dshHome, 'dsh'), DSH_TELEMETRY_DISABLED: '1' }) } }) });
|
|
122
|
+
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
123
|
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
124
|
hcc('web', '--local', '--port', String(port), '--no-discover', '--no-guidance'); runtimeStarted = true;
|
|
101
125
|
const { chromium } = await import(process.env.HCC_ACCEPTANCE_PLAYWRIGHT);
|
|
@@ -161,7 +185,7 @@ try {
|
|
|
161
185
|
await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await page.locator('#nativeApprovals button[data-decision="decline"]').click();
|
|
162
186
|
await finish(peer, declined.submission_id); assert.equal(fs.existsSync(denied), false);
|
|
163
187
|
check('Codex model respects a declined real filesystem permission');
|
|
164
|
-
} else {
|
|
188
|
+
} else if (provider === 'claude') {
|
|
165
189
|
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
190
|
const request = await pending(peer, 'approval'); assert.equal(request.method, 'claude/canUseTool'); assert.equal(request.params.tool, 'Write');
|
|
167
191
|
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 +197,53 @@ try {
|
|
|
173
197
|
const refusal = await pending(peer, 'approval'); assert.equal(refusal.params.tool, 'Write'); assert.equal(refusal.params.input.file_path, denied);
|
|
174
198
|
await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await page.locator('#nativeApprovals button[data-decision="decline"]').click();
|
|
175
199
|
await finish(peer, declined.submission_id); assert.equal(fs.existsSync(denied), false); check('Claude model respects a declined real Write operation');
|
|
200
|
+
} else {
|
|
201
|
+
const before = await state(peer); result.sessionId = before.snapshot.sessionId;
|
|
202
|
+
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.');
|
|
203
|
+
const request = await pending(peer, 'approval');
|
|
204
|
+
assert.equal(request.method, 'session/request_permission'); assert.equal(request.sessionId, result.sessionId);
|
|
205
|
+
assert.equal(request.params.toolCall.rawInput.file_path, allowed); assert.equal(request.params.toolCall.rawInput.content, 'DSH_APPROVED_OK');
|
|
206
|
+
assert.ok(request.params.options.some(value => value.kind === 'allow_once'));
|
|
207
|
+
result.permission = { method: request.method, requestId: request.requestId, sessionId: request.sessionId, turnId: request.turnId,
|
|
208
|
+
toolCall: request.params.toolCall, approvalScope: 'allow_once' };
|
|
209
|
+
await page.locator('#nativeApprovals button[data-decision="accept"]').waitFor({ timeout: 15000 }); await shot('dsh-real-permission');
|
|
210
|
+
assert.ok((await page.locator('#nativeApprovals').innerText()).includes(allowed), 'browser must show the actual bounded operation before approval');
|
|
211
|
+
const details = page.locator('#nativeApprovals [data-interaction-details]');
|
|
212
|
+
assert.equal(await details.evaluate(element => element.open), false, 'protocol details start collapsed');
|
|
213
|
+
await details.locator('summary').click(); await details.evaluate(element => { element.dataset.acceptanceBeforeRefresh = '1'; });
|
|
214
|
+
await page.locator('#nativeRead').click();
|
|
215
|
+
await page.waitForFunction(() => !document.querySelector('#nativeApprovals [data-acceptance-before-refresh]'));
|
|
216
|
+
assert.equal(await details.evaluate(element => element.open), true, 'full operation details survive state refresh');
|
|
217
|
+
await details.locator('summary').click();
|
|
218
|
+
assert.ok(await page.evaluate(() => {
|
|
219
|
+
const button = document.querySelector('#nativeApprovals button[data-decision="accept"]').getBoundingClientRect();
|
|
220
|
+
const scroll = document.querySelector('.native-scroll').getBoundingClientRect();
|
|
221
|
+
return button.y >= scroll.y && button.bottom <= scroll.bottom;
|
|
222
|
+
}), 'desktop approval is reachable without scrolling past protocol details');
|
|
223
|
+
await shot('dsh-real-permission-summary');
|
|
224
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
225
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), 'mobile page has no horizontal overflow');
|
|
226
|
+
await page.locator('#nativeApprovals button[data-decision="accept"]').scrollIntoViewIfNeeded();
|
|
227
|
+
assert.equal(await page.locator('#nativeApprovals button[data-decision="accept"]').isEnabled(), true);
|
|
228
|
+
await shot('dsh-real-permission-mobile');
|
|
229
|
+
check('dsh operation summary and persistent details render on desktop and 390px mobile');
|
|
230
|
+
await page.setViewportSize({ width: 1440, height: 1000 });
|
|
231
|
+
await page.locator('#nativeApprovals button[data-decision="accept"]').click(); await finish(peer, delivery.submission_id);
|
|
232
|
+
assert.equal(fs.readFileSync(allowed, 'utf8').trim(), 'DSH_APPROVED_OK');
|
|
233
|
+
assert.equal((await state(peer)).snapshot.sessionId, result.sessionId);
|
|
234
|
+
check('dsh real ACP write approval returns to the original model turn');
|
|
235
|
+
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.');
|
|
236
|
+
const refusal = await pending(peer, 'approval'); assert.equal(refusal.method, 'session/request_permission');
|
|
237
|
+
assert.equal(refusal.sessionId, result.sessionId); assert.equal(refusal.params.toolCall.rawInput.file_path, denied);
|
|
238
|
+
result.refusal = { method: refusal.method, requestId: refusal.requestId, sessionId: refusal.sessionId, turnId: refusal.turnId, target: denied };
|
|
239
|
+
await page.locator('#nativeApprovals button[data-decision="decline"]').waitFor({ timeout: 15000 }); await shot('dsh-real-permission-decline');
|
|
240
|
+
await page.locator('#nativeApprovals button[data-decision="decline"]').click(); await finish(peer, declined.submission_id);
|
|
241
|
+
assert.equal(fs.existsSync(denied), false); check('dsh model respects a declined real ACP write operation');
|
|
176
242
|
}
|
|
177
243
|
await page.locator('#releaseControlBtn').click(); await page.waitForFunction(() => !window.hccHandoff.canControl);
|
|
178
244
|
const followup = await api('POST', '/send', { peer, from: 'acceptance-local', submissionId: randomUUID(), body: 'No tool calls. Return INTERACTION_LOCAL_RETURN_OK.' });
|
|
179
245
|
const after = await finish(peer, followup.submission_id); assert.equal(after.snapshot.sessionId, result.sessionId);
|
|
246
|
+
result.localReturn = { sessionId: after.snapshot.sessionId, submissionId: followup.submission_id };
|
|
180
247
|
result.completed = true; check(provider + ' local return retains original provider session');
|
|
181
248
|
}
|
|
182
249
|
assert.deepEqual(evidence.pageErrors, []); assert.deepEqual(evidence.consoleErrors, []);
|
|
@@ -197,8 +264,8 @@ try {
|
|
|
197
264
|
spawnSync(tmux, ['-L', socket, 'kill-server'], { stdio: 'ignore' });
|
|
198
265
|
evidence.protectedConfigUnchanged = protectedHashes.every(([file, before]) => fs.existsSync(file) && digest(file) === before);
|
|
199
266
|
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);
|
|
267
|
+
for (const folder of [root, bin, codexHome, claudeHome, dshHome, outside]) fs.rmSync(folder, { recursive: true, force: true });
|
|
268
|
+
evidence.temporaryProviderHomesRemoved = !fs.existsSync(codexHome) && !fs.existsSync(claudeHome) && !fs.existsSync(dshHome);
|
|
202
269
|
evidence.finishedAt = new Date().toISOString(); fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(evidence, null, 2) + '\n', { mode: 0o600 });
|
|
203
270
|
console.log(JSON.stringify({ evidence: output, completed: evidence.completed, protectedConfigUnchanged: evidence.protectedConfigUnchanged }));
|
|
204
271
|
}
|