@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
package/scripts/regression.mjs
CHANGED
|
@@ -41,6 +41,7 @@ const managedTmuxSessions = new Set();
|
|
|
41
41
|
|
|
42
42
|
const env = {
|
|
43
43
|
...process.env,
|
|
44
|
+
NODE_NO_WARNINGS: '1',
|
|
44
45
|
HOME: home,
|
|
45
46
|
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
|
|
46
47
|
SHELL: '/bin/bash'
|
|
@@ -2449,7 +2450,7 @@ async function assertShimIgnoresGlobalRuntime(generateShim) {
|
|
|
2449
2450
|
started_at: Math.floor(Date.now() / 1000),
|
|
2450
2451
|
global_runtime: true
|
|
2451
2452
|
};
|
|
2452
|
-
fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2));
|
|
2453
|
+
fs.writeFileSync(path.join(fakeHome, '.hello-cc', 'runtime.json'), JSON.stringify(globalRuntime, null, 2), { mode: 0o600 });
|
|
2453
2454
|
|
|
2454
2455
|
const hccWrapper = path.join(testDir, 'hcc-wrapper');
|
|
2455
2456
|
fs.writeFileSync(hccWrapper, `#!/usr/bin/env bash\nexec ${sh(process.execPath)} ${sh(hccBin)} "$@"\n`, { mode: 0o755 });
|
|
@@ -2553,13 +2554,17 @@ function startRuntime(options = {}) {
|
|
|
2553
2554
|
async function cookieSessionExpiryWorkflow() {
|
|
2554
2555
|
const expirySessionId = `cookie-expiry-${testId}`;
|
|
2555
2556
|
const marker = `EXPIRED_COOKIE_INPUT_${testId}`;
|
|
2557
|
+
// Sessions use whole-second timestamps. A one-second TTL can expire during
|
|
2558
|
+
// the initial WebSocket/PTY snapshot, before this test reaches its assertion.
|
|
2559
|
+
// Keep a bounded setup window, then explicitly wait past the same TTL.
|
|
2560
|
+
const cookieTtlSec = 10;
|
|
2556
2561
|
let ws = null;
|
|
2557
2562
|
|
|
2558
2563
|
startRuntime({
|
|
2559
2564
|
env: {
|
|
2560
2565
|
...env,
|
|
2561
2566
|
HCC_REGRESSION_TEST: '1',
|
|
2562
|
-
HCC_REGRESSION_WEB_SESSION_TTL_SEC:
|
|
2567
|
+
HCC_REGRESSION_WEB_SESSION_TTL_SEC: String(cookieTtlSec)
|
|
2563
2568
|
}
|
|
2564
2569
|
});
|
|
2565
2570
|
try {
|
|
@@ -2588,7 +2593,7 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2588
2593
|
ws = await openCookieTerminalWebSocket(expirySessionId, auth.sid, { root });
|
|
2589
2594
|
if (!ws.hccActionToken) fail('short-TTL cookie terminal snapshot omitted its action token');
|
|
2590
2595
|
const closed = new Promise((resolve, reject) => {
|
|
2591
|
-
const timer = setTimeout(() => reject(new Error('expired cookie websocket close timeout')),
|
|
2596
|
+
const timer = setTimeout(() => reject(new Error('expired cookie websocket close timeout')), (cookieTtlSec + 5) * 1000);
|
|
2592
2597
|
ws.once('close', (code, reason) => {
|
|
2593
2598
|
clearTimeout(timer);
|
|
2594
2599
|
resolve({ code, reason: String(reason || '') });
|
|
@@ -2599,7 +2604,7 @@ async function cookieSessionExpiryWorkflow() {
|
|
|
2599
2604
|
});
|
|
2600
2605
|
});
|
|
2601
2606
|
|
|
2602
|
-
await sleep(
|
|
2607
|
+
await sleep(cookieTtlSec * 1000 + 200);
|
|
2603
2608
|
ws.send(JSON.stringify({
|
|
2604
2609
|
type: 'input',
|
|
2605
2610
|
data: `echo ${marker}\r`,
|
|
@@ -2808,6 +2813,10 @@ function cleanup() {
|
|
|
2808
2813
|
if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
|
|
2809
2814
|
runMaybe('tmux', ['kill-server']);
|
|
2810
2815
|
}
|
|
2816
|
+
if (process.env.HCC_REGRESSION_PRESERVE_ARTIFACTS === '1') {
|
|
2817
|
+
log(`regression artifacts preserved: ${root}`);
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2811
2820
|
for (const dir of [root, home, fakeBin, outDir]) {
|
|
2812
2821
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
2813
2822
|
}
|
|
@@ -4420,7 +4429,14 @@ async function multiProjectWebWorkflow() {
|
|
|
4420
4429
|
|
|
4421
4430
|
const htmlResponse = await fetch(currentRuntimeUrl('/'));
|
|
4422
4431
|
const html = await htmlResponse.text();
|
|
4423
|
-
|
|
4432
|
+
const startFormHtml = html.match(/<form\b[^>]*\bid="startForm"[^>]*>[\s\S]*?<\/form>/)?.[0];
|
|
4433
|
+
if (!startFormHtml) fail('web form missing simplified session form');
|
|
4434
|
+
// A current-directory label in an approval or session details is valid;
|
|
4435
|
+
// obsolete creation inputs must be checked inside the creation form.
|
|
4436
|
+
for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory']) {
|
|
4437
|
+
if (startFormHtml.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
|
|
4438
|
+
}
|
|
4439
|
+
for (const forbidden of ['commandbar', 'lineInput', 'Send text to active terminal']) {
|
|
4424
4440
|
if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
|
|
4425
4441
|
}
|
|
4426
4442
|
for (const expected of [
|
|
@@ -5538,7 +5554,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5538
5554
|
|
|
5539
5555
|
const liveId = `gc-live-external-${testId}`;
|
|
5540
5556
|
const liveFiles = ['out', 'in', 'resize', 'meta'].map((suffix) => path.join(rootBufs, `${liveId}.${suffix}`));
|
|
5541
|
-
for (const file of liveFiles.slice(0, 3)) fs.writeFileSync(file, file.endsWith('.out') ? 'live\n' : '');
|
|
5557
|
+
for (const file of liveFiles.slice(0, 3)) fs.writeFileSync(file, file.endsWith('.out') ? 'live\n' : '', { mode: 0o600 });
|
|
5542
5558
|
const identity = inspectProcessIdentity(process.pid).identity;
|
|
5543
5559
|
fs.writeFileSync(liveFiles[3], JSON.stringify({
|
|
5544
5560
|
id: liveId,
|
|
@@ -5552,7 +5568,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5552
5568
|
wrapper_identity: identity,
|
|
5553
5569
|
cols: 120,
|
|
5554
5570
|
rows: 40
|
|
5555
|
-
}));
|
|
5571
|
+
}), { mode: 0o600 });
|
|
5556
5572
|
await waitFor(async () => {
|
|
5557
5573
|
const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
5558
5574
|
return (data.sessions || []).some((session) => session.id === liveId);
|
|
@@ -5561,8 +5577,8 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5561
5577
|
|
|
5562
5578
|
const legacyId = `gc-legacy-${testId}`;
|
|
5563
5579
|
const legacyFiles = ['out', 'in', 'resize', 'meta'].map((suffix) => path.join(rootBufs, `${legacyId}.${suffix}`));
|
|
5564
|
-
for (const file of legacyFiles.slice(0, 3)) fs.writeFileSync(file, '');
|
|
5565
|
-
fs.writeFileSync(legacyFiles[3], JSON.stringify({ id: legacyId, pid: process.pid, wrapper_pid: process.pid }));
|
|
5580
|
+
for (const file of legacyFiles.slice(0, 3)) fs.writeFileSync(file, '', { mode: 0o600 });
|
|
5581
|
+
fs.writeFileSync(legacyFiles[3], JSON.stringify({ id: legacyId, pid: process.pid, wrapper_pid: process.pid }), { mode: 0o600 });
|
|
5566
5582
|
for (const file of legacyFiles) fs.utimesSync(file, oldTime, oldTime);
|
|
5567
5583
|
await waitFor(async () => {
|
|
5568
5584
|
const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
@@ -5572,7 +5588,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5572
5588
|
const siblingLiveId = `gc-sibling-external-${testId}`;
|
|
5573
5589
|
const siblingLiveFiles = ['out', 'in', 'resize', 'meta']
|
|
5574
5590
|
.map((suffix) => path.join(siblingBufs, `${siblingLiveId}.${suffix}`));
|
|
5575
|
-
for (const file of siblingLiveFiles.slice(0, 3)) fs.writeFileSync(file, '');
|
|
5591
|
+
for (const file of siblingLiveFiles.slice(0, 3)) fs.writeFileSync(file, '', { mode: 0o600 });
|
|
5576
5592
|
fs.writeFileSync(siblingLiveFiles[3], JSON.stringify({
|
|
5577
5593
|
id: siblingLiveId,
|
|
5578
5594
|
kind: 'shell',
|
|
@@ -5585,7 +5601,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5585
5601
|
wrapper_identity: identity,
|
|
5586
5602
|
cols: 120,
|
|
5587
5603
|
rows: 40
|
|
5588
|
-
}));
|
|
5604
|
+
}), { mode: 0o600 });
|
|
5589
5605
|
await waitFor(async () => {
|
|
5590
5606
|
const data = await (await runtimeFetch('/api/sessions', {}, { root: secondProjectRoot })).json();
|
|
5591
5607
|
return (data.sessions || []).some((session) => session.id === siblingLiveId);
|
|
@@ -5884,7 +5900,7 @@ async function bufferGcArbitrationWorkflow() {
|
|
|
5884
5900
|
base_url: server?.baseUrl || 'http://127.0.0.1:1',
|
|
5885
5901
|
token: 'regression-token'
|
|
5886
5902
|
};
|
|
5887
|
-
fs.writeFileSync(path.join(isolatedRoot, '.hello-cc', 'runtime.json'), JSON.stringify(pointer));
|
|
5903
|
+
fs.writeFileSync(path.join(isolatedRoot, '.hello-cc', 'runtime.json'), JSON.stringify(pointer), { mode: 0o600 });
|
|
5888
5904
|
const gc = run(process.execPath, [hccBin, '--root', isolatedRoot, '--json', 'gc', '--older-than', '0', '--yes'], { env: isolatedEnv });
|
|
5889
5905
|
const payload = JSON.parse(gc);
|
|
5890
5906
|
if (!fs.existsSync(orphan) || Number(payload.data?.deferred_buf_files || 0) < 1) {
|
|
@@ -7545,7 +7561,7 @@ async function syntaxAndHelp() {
|
|
|
7545
7561
|
fail('runtime state clearRuntime did not remove the reused-pid global pointer');
|
|
7546
7562
|
}
|
|
7547
7563
|
|
|
7548
|
-
fs.writeFileSync(globalRuntimeFile, '{bad');
|
|
7564
|
+
fs.writeFileSync(globalRuntimeFile, '{bad', { mode: 0o600 });
|
|
7549
7565
|
if (runtimeState.readGlobalRuntimeFile() !== null) {
|
|
7550
7566
|
fail('runtime state readGlobalRuntimeFile did not reject invalid JSON');
|
|
7551
7567
|
}
|
|
@@ -9493,7 +9509,7 @@ async function syntaxAndHelp() {
|
|
|
9493
9509
|
process.env.HOME = tlsHome;
|
|
9494
9510
|
const webTls = await import(path.join(repoRoot, 'lib', 'web', 'tls.mjs'));
|
|
9495
9511
|
tlsCredentials = webTls.ensureSelfSignedCert();
|
|
9496
|
-
const tlsDir = path.join(tlsHome, '.hello-cc', 'tls');
|
|
9512
|
+
const tlsDir = path.join(fs.realpathSync(tlsHome), '.hello-cc', 'tls');
|
|
9497
9513
|
const currentPointerPath = path.join(tlsDir, 'current.json');
|
|
9498
9514
|
const currentGeneration = JSON.parse(fs.readFileSync(currentPointerPath, 'utf8')).generation;
|
|
9499
9515
|
const generationNames = {
|
|
@@ -9529,27 +9545,28 @@ async function syntaxAndHelp() {
|
|
|
9529
9545
|
}
|
|
9530
9546
|
const originalTlsCert = tlsCredentials.cert;
|
|
9531
9547
|
const originalTlsCertPath = tlsCredentials.certPath;
|
|
9532
|
-
const
|
|
9533
|
-
let
|
|
9548
|
+
const originalLstatSync = fs.lstatSync;
|
|
9549
|
+
let pointerChecks = 0;
|
|
9534
9550
|
let pointerSwitchedBeforeDelete = false;
|
|
9535
|
-
fs.
|
|
9551
|
+
fs.lstatSync = (file, ...args) => {
|
|
9536
9552
|
if (path.resolve(String(file)) === path.resolve(currentPointerPath)) {
|
|
9537
|
-
|
|
9538
|
-
//
|
|
9539
|
-
//
|
|
9540
|
-
|
|
9553
|
+
pointerChecks += 1;
|
|
9554
|
+
// The private reader lstat-checks before and after each descriptor
|
|
9555
|
+
// read. Checks 1-2 validate the old current; 3-4 start cleanup; check
|
|
9556
|
+
// 5 is the deletion-time guard for the stale switched-current row.
|
|
9557
|
+
if (pointerChecks === 5) {
|
|
9541
9558
|
const nextPointerPath = path.join(tlsDir, `.current-regression-${testId}.tmp`);
|
|
9542
9559
|
fs.writeFileSync(nextPointerPath, `${JSON.stringify({ generation: generationNames.switchedCurrent })}\n`, { mode: 0o600 });
|
|
9543
9560
|
fs.renameSync(nextPointerPath, currentPointerPath);
|
|
9544
9561
|
pointerSwitchedBeforeDelete = true;
|
|
9545
9562
|
}
|
|
9546
9563
|
}
|
|
9547
|
-
return
|
|
9564
|
+
return originalLstatSync(file, ...args);
|
|
9548
9565
|
};
|
|
9549
9566
|
try {
|
|
9550
9567
|
tlsCredentials = webTls.ensureSelfSignedCert();
|
|
9551
9568
|
} finally {
|
|
9552
|
-
fs.
|
|
9569
|
+
fs.lstatSync = originalLstatSync;
|
|
9553
9570
|
}
|
|
9554
9571
|
const publishedGeneration = JSON.parse(fs.readFileSync(currentPointerPath, 'utf8')).generation;
|
|
9555
9572
|
const remainingGenerations = fs.readdirSync(tlsDir, { withFileTypes: true })
|
|
@@ -9565,14 +9582,14 @@ async function syntaxAndHelp() {
|
|
|
9565
9582
|
].sort();
|
|
9566
9583
|
if (tlsCredentials.cert !== originalTlsCert ||
|
|
9567
9584
|
tlsCredentials.certPath !== originalTlsCertPath ||
|
|
9568
|
-
!pointerSwitchedBeforeDelete ||
|
|
9585
|
+
!pointerSwitchedBeforeDelete || pointerChecks < 6 ||
|
|
9569
9586
|
publishedGeneration !== generationNames.switchedCurrent ||
|
|
9570
9587
|
!fs.existsSync(path.join(tlsDir, generationNames.initialCurrent, '.published')) ||
|
|
9571
9588
|
!fs.existsSync(path.join(tlsDir, generationNames.switchedCurrent, '.published')) ||
|
|
9572
9589
|
!fs.existsSync(path.join(tlsDir, generationNames.activeCreating, '.creating')) ||
|
|
9573
9590
|
fs.existsSync(path.join(tlsDir, generationNames.deadCreating)) ||
|
|
9574
9591
|
JSON.stringify(remainingGenerations) !== JSON.stringify(expectedRemainingGenerations)) {
|
|
9575
|
-
fail(`TLS generation cleanup violated current/previous/candidate lifecycle protection:\n${JSON.stringify({ generationNames,
|
|
9592
|
+
fail(`TLS generation cleanup violated current/previous/candidate lifecycle protection:\n${JSON.stringify({ generationNames, pointerChecks, pointerSwitchedBeforeDelete, publishedGeneration, remainingGenerations }, null, 2)}`);
|
|
9576
9593
|
}
|
|
9577
9594
|
} finally {
|
|
9578
9595
|
if (savedTlsHome === undefined) delete process.env.HOME;
|
|
@@ -10133,9 +10150,9 @@ async function processEvidenceWorkflow() {
|
|
|
10133
10150
|
const legacyProcess = spawn('sleep', ['30'], { stdio: 'ignore' });
|
|
10134
10151
|
const legacyFiles = ['out', 'in', 'resize', 'meta']
|
|
10135
10152
|
.map((suffix) => path.join(externalDir, `${legacyExternalId}.${suffix}`));
|
|
10136
|
-
fs.writeFileSync(legacyFiles[0], 'legacy external\n');
|
|
10137
|
-
fs.writeFileSync(legacyFiles[1], '');
|
|
10138
|
-
fs.writeFileSync(legacyFiles[2], '');
|
|
10153
|
+
fs.writeFileSync(legacyFiles[0], 'legacy external\n', { mode: 0o600 });
|
|
10154
|
+
fs.writeFileSync(legacyFiles[1], '', { mode: 0o600 });
|
|
10155
|
+
fs.writeFileSync(legacyFiles[2], '', { mode: 0o600 });
|
|
10139
10156
|
fs.writeFileSync(legacyFiles[3], JSON.stringify({
|
|
10140
10157
|
id: legacyExternalId,
|
|
10141
10158
|
kind: 'shell',
|
|
@@ -10146,7 +10163,7 @@ async function processEvidenceWorkflow() {
|
|
|
10146
10163
|
wrapper_pid: legacyProcess.pid,
|
|
10147
10164
|
cols: 120,
|
|
10148
10165
|
rows: 40
|
|
10149
|
-
}));
|
|
10166
|
+
}), { mode: 0o600 });
|
|
10150
10167
|
await waitFor(async () => {
|
|
10151
10168
|
const data = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
10152
10169
|
return (data.sessions || []).some((session) => session.id === legacyExternalId);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Browser acceptance with isolated simulated providers. No real model or provider authentication is used.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
if (!process.argv.includes('--run-browser')) { console.log('Use --run-browser with HCC_ACCEPTANCE_PLAYWRIGHT and optional HCC_ACCEPTANCE_CHROME. Uses simulated providers and isolated projects; no model calls.'); process.exit(0); }
|
|
11
|
+
const { chromium } = await import(process.env.HCC_ACCEPTANCE_PLAYWRIGHT || 'playwright');
|
|
12
|
+
import { createEventHelpers } from '../lib/db/events.mjs';
|
|
13
|
+
import { createPeerHelpers } from '../lib/core/peers/peer-helpers.mjs';
|
|
14
|
+
import { createPeerBindingStore } from '../lib/db/stores/peers.mjs';
|
|
15
|
+
import { createMessageStore } from '../lib/core/coordination/messages.mjs';
|
|
16
|
+
import { inspectProcessIdentity } from '../lib/process/identity.mjs';
|
|
17
|
+
import { startNativeService } from '../lib/runtime/native/service.mjs';
|
|
18
|
+
import { nativeRequest } from '../lib/runtime/native/client.mjs';
|
|
19
|
+
import { createNativeInteractions } from '../lib/integrations/native/interactions.mjs';
|
|
20
|
+
import { codexInteractionResponse, codexInteractionKind, cancelCodexInteraction } from '../lib/integrations/codex-interactions.mjs';
|
|
21
|
+
const node=process.execPath, repo=fileURLToPath(new URL('..',import.meta.url));
|
|
22
|
+
const dir=fs.realpathSync(fs.mkdtempSync('/tmp/hcc-interactions-ui-')),root=path.join(dir,'project'),home=path.join(dir,'home'),bin=path.join(dir,'bin'),socket='hcc-interactions-'+Date.now();
|
|
23
|
+
for(const folder of [root,home,bin])fs.mkdirSync(folder);
|
|
24
|
+
const formParams={mode:'form',serverName:'form-fixture',message:'Provide project settings <script>bad()</script>',requestedSchema:{type:'object',properties:{name:{type:'string',title:'Project <name>',minLength:3,maxLength:20},count:{type:'integer',minimum:1,maximum:4},enabled:{type:'boolean'},region:{type:'string',oneOf:[{const:'eu',title:'Europe'},{const:'us',title:'USA'}]},features:{type:'array',minItems:1,maxItems:2,items:{anyOf:[{const:'logs',title:'Logs'},{const:'tests',title:'Tests'}]}},optional:{type:'string',default:'suggested'}},required:['name','count','enabled','region','features']}};
|
|
25
|
+
const env={PATH:bin+':'+path.dirname(node)+':/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin',HOME:home,SHELL:'/bin/bash',LANG:'en_US.UTF-8',TMPDIR:dir,CODEX_HOME:path.join(home,'.codex'),HCC_NO_AUTO_INSTALL_TMUX:'1',HCC_WEB_TOKEN:'qa-interactive-isolated',HCC_TEST_MCP_RPC:path.join(dir,'mcp-rpc.jsonl')};
|
|
26
|
+
const tmux=spawnSync('which',['tmux'],{encoding:'utf8'}).stdout.trim();if(!tmux||!fs.existsSync(tmux))throw Error('tmux is required for isolated Web runtime acceptance');
|
|
27
|
+
fs.writeFileSync(path.join(bin,'tmux'),'#!/bin/sh\nexec '+JSON.stringify(tmux)+' -L '+socket+' "$@"\n',{mode:0o700});
|
|
28
|
+
fs.writeFileSync(path.join(bin,'codex'),`#!${node}
|
|
29
|
+
import readline from 'node:readline';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
const send=x=>process.stdout.write(JSON.stringify(x)+'\\n'),reply=(m,r)=>send({id:m.id,result:r});
|
|
32
|
+
let thread={id:'qa-thread',cwd:process.cwd(),status:{type:'idle'},turns:[]}, pending, turn;
|
|
33
|
+
const req=(method,params)=>{pending=params.mode==='url'?'mcp-url':method;send({id:params.mode==='url'?'mcp-url':method==='mcpServer/elicitation/request'?0:method,method,params:{threadId:thread.id,turnId:turn.id,itemId:method,...params}})};
|
|
34
|
+
readline.createInterface({input:process.stdin}).on('line',line=>{
|
|
35
|
+
const m=JSON.parse(line),p=m.params||{};
|
|
36
|
+
if(!m.method){fs.appendFileSync(process.env.HCC_TEST_MCP_RPC,JSON.stringify(m)+'\\n');send({method:'serverRequest/resolved',params:{threadId:thread.id,requestId:m.id}});if(pending==='item/permissions/requestApproval'){req('item/tool/requestUserInput',{questions:[{id:'choice',header:'Choice',question:'Which mode?',options:[{label:'Blue',description:'Blue mode'},{label:'Red',description:'Red mode'}]}]});}else if(pending==='item/tool/requestUserInput'){req('mcpServer/elicitation/request',${JSON.stringify(formParams)});}else if(pending==='mcpServer/elicitation/request'){req('mcpServer/elicitation/request',{mode:'url',serverName:'form-fixture',message:'External authentication',url:'javascript:alert(1)',elicitationId:'unsupported-url'});}else{turn.status='completed';send({method:'turn/completed',params:{threadId:thread.id,turn}});}return;}
|
|
37
|
+
if(m.method==='initialized')return;
|
|
38
|
+
if(m.method==='initialize')return reply(m,{userAgent:'ui-fake'});
|
|
39
|
+
if(m.method==='thread/list')return reply(m,{data:[thread],nextCursor:null});
|
|
40
|
+
if(['thread/start','thread/resume','thread/read'].includes(m.method))return reply(m,{thread});
|
|
41
|
+
if(m.method==='turn/start'){turn={id:'qa-turn',status:'inProgress',items:[]};thread.turns.push(turn);thread.status={type:'active'};reply(m,{turn});req('item/permissions/requestApproval',{cwd:process.cwd(),reason:'Test bounded permission',permissions:{network:{enabled:true},fileSystem:{read:['/read-only'],write:['/project']}}});return;}
|
|
42
|
+
reply(m,{});
|
|
43
|
+
});
|
|
44
|
+
`,{mode:0o700});
|
|
45
|
+
function sourceHashes(){const result={};const walk=folder=>{for(const entry of fs.readdirSync(path.join(repo,folder),{withFileTypes:true})){const file=path.join(folder,entry.name);if(entry.isDirectory())walk(file);else if(entry.isFile())result[file]=createHash('sha256').update(fs.readFileSync(path.join(repo,file))).digest('hex');}};for(const folder of ['bin','lib','scripts','test'])walk(folder);for(const file of ['package.json','package-lock.json'])result[file]=createHash('sha256').update(fs.readFileSync(path.join(repo,file))).digest('hex');return result;}
|
|
46
|
+
const evidence={dir,checks:[],screenshots:[],pageErrors:[],consoleErrors:[],browser:'Isolated installed Chrome + Playwright; browser-extension connectivity is not exercised',providerMode:'simulated protocol requests; production HTTP, SQLite, worker and browser paths; no inference',node:process.version,sourceRoot:repo,sourceFiles:sourceHashes(),startedAt:new Date().toISOString()};
|
|
47
|
+
const check=(name,extra={})=>{evidence.checks.push({name,...extra});console.log('PASS '+name);};
|
|
48
|
+
function hcc(...args){const r=spawnSync(node,[path.join(repo,'bin/hcc.mjs'),'--root',root,...args],{cwd:root,env,encoding:'utf8',timeout:30000});if(r.status!==0)throw Error('Isolated CLI failed '+args[0]);return r.stdout;}
|
|
49
|
+
const ctx={root,dbPath:path.join(root,'.hello-cc','mesh.db')},events=createEventHelpers(),binding=createPeerBindingStore(events),peer=createPeerHelpers({now:()=>Math.floor(Date.now()/1000),...events});
|
|
50
|
+
const deps={...events,...binding,...peer,...createMessageStore(events),connect:()=>new DatabaseSync(ctx.dbPath),detectBranch:()=>'',liveProcessIdentity:pid=>inspectProcessIdentity(pid).identity};
|
|
51
|
+
let browser,service,runtime=false,state,interactions,emit,adapter;
|
|
52
|
+
const api=(method,route,body)=>nativeRequest(ctx,method,route,body);
|
|
53
|
+
const port=await new Promise(resolve=>{const s=net.createServer();s.listen(0,'127.0.0.1',()=>{const p=s.address().port;s.close(()=>resolve(p));});}),base='http://127.0.0.1:'+port;
|
|
54
|
+
const shot=async(page,name)=>{
|
|
55
|
+
const file=path.join(dir,name+'.png');
|
|
56
|
+
// Viewport changes can leave an old compositor frame after layout settles.
|
|
57
|
+
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
|
58
|
+
await page.screenshot({path:file});evidence.screenshots.push(file);
|
|
59
|
+
};
|
|
60
|
+
const wait=(page,fn)=>page.waitForFunction(fn,null,{timeout:12000});
|
|
61
|
+
function ask(id,method,params){state.turnId='native-turn';state.status='running';return interactions.request({requestId:id,sessionId:state.sessionId,turnId:state.turnId,method,kind:codexInteractionKind(method),params,validate:codexInteractionResponse,cancelled:cancelCodexInteraction(method)});}
|
|
62
|
+
try{
|
|
63
|
+
hcc('up','--no-discover','--no-guidance');
|
|
64
|
+
service=await startNativeService(ctx,deps,{pollMs:60000,adapterFactory:async(provider,options)=>{
|
|
65
|
+
state={provider,status:'idle',sessionId:'native-qa-session',turnId:null,executorId:options.executorId,capabilities:{send:true,interrupt:true,close:true,approvals:true,userInput:true}};emit=options.onEvent;
|
|
66
|
+
interactions=createNativeInteractions({executorId:options.executorId,isActive:(session,turn)=>session===state.sessionId&&turn===state.turnId&&state.status==='running',onChange:emit});
|
|
67
|
+
adapter={capabilities:state.capabilities,snapshot:()=>({...state,pendingApprovals:interactions.snapshot()}),async open(){return this.snapshot();},respond:interactions.respond,async interrupt(){interactions.expire();state.turnId=null;state.status='idle';return{status:'interrupting'};},async close(){interactions.expire();state.status='closed';}};return adapter;
|
|
68
|
+
}});
|
|
69
|
+
await api('POST','/workers',{peer:'native-qa',provider:'codex'});
|
|
70
|
+
hcc('web','--local','--port',String(port),'--no-discover','--no-guidance');runtime=true;
|
|
71
|
+
browser=await chromium.launch({headless:true,executablePath:process.env.HCC_ACCEPTANCE_CHROME||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
|
72
|
+
const a=await browser.newContext({viewport:{width:1440,height:1000}}),b=await browser.newContext({viewport:{width:1280,height:900}});
|
|
73
|
+
for(const c of [a,b])await c.addInitScript(()=>localStorage.setItem('hcc.lang','en'));
|
|
74
|
+
const p1=await a.newPage(),p2=await b.newPage();
|
|
75
|
+
for(const [p,label]of [[p1,'controller'],[p2,'observer']]){p.on('pageerror',e=>evidence.pageErrors.push({label,message:e.message}));p.on('console',m=>{if(['warning','error'].includes(m.type()))evidence.consoleErrors.push({label,text:m.text()});});p.on('dialog',d=>d.accept());}
|
|
76
|
+
await p1.goto(base+'/?token='+env.HCC_WEB_TOKEN);await wait(p1,()=>window.hccHandoff?.canControl&&window.hccHandoff.session?.type==='native');
|
|
77
|
+
assert.equal(await p1.title(),'hello-cc');assert.ok((await p1.locator('body').innerText()).length>200);check('page identity, nonblank, native view and no error overlay');
|
|
78
|
+
const command=ask('command','item/commandExecution/requestApproval',{command:'echo <script>bad()</script>',cwd:root});
|
|
79
|
+
await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals button[data-decision="accept"]').waitFor();
|
|
80
|
+
await p2.goto(base+'/?token='+env.HCC_WEB_TOKEN);await wait(p2,()=>window.hccHandoff?.session?.type==='native'&&window.hccHandoff.actionToken);
|
|
81
|
+
assert.equal(await p2.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);assert.equal(await p1.locator('#nativeApprovals script').count(),0);check('observer approvals disabled and request previews escape HTML');
|
|
82
|
+
await p1.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await command,{decision:'accept'});await wait(p1,()=>!document.querySelector('#nativeApprovals button'));check('manual command approval resolves the original request and removes its card');
|
|
83
|
+
const permissions=ask('permissions','item/permissions/requestApproval',{permissions:{network:{enabled:true},fileSystem:{read:['/read-only'],write:['/write-only']}}});
|
|
84
|
+
await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals [data-permission="read"]').check();
|
|
85
|
+
await shot(p1,'native-permissions-desktop');
|
|
86
|
+
await p1.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await permissions,{permissions:{fileSystem:{read:['/read-only']}},scope:'turn'});check('permissions submit only selected read path with explicit turn duration');
|
|
87
|
+
const questions=ask('questions','item/tool/requestUserInput',{questions:[{id:'color',header:'Color',question:'Choose color',isOther:true,options:[{label:'Blue',description:'Blue option'},{label:'Red',description:'Red option'}]},{id:'secret',header:'Secret',question:'Private input',isSecret:true}]});
|
|
88
|
+
await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals button[data-decision="accept"]').click();await wait(p1,()=>document.getElementById('nativeNotice').textContent.includes('answer every question'));assert.equal(interactions.snapshot().length,1);check('unanswered questions remain pending');
|
|
89
|
+
await p1.locator('#nativeApprovals [data-question="0"]').selectOption('other');await p1.locator('#nativeApprovals [data-answer="0"]').fill('Green');await p1.locator('#nativeApprovals [data-answer="1"]').fill('private-qa-answer');
|
|
90
|
+
await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals [data-answer="0"]').inputValue(),'Green');assert.equal(await p1.locator('#nativeApprovals [data-answer="1"]').inputValue(),'private-qa-answer');check('question draft and secret input survive state refresh without local storage');
|
|
91
|
+
await p1.locator('#nativeApprovals button[data-decision="accept"]').click();const answers=await questions;assert.deepEqual(JSON.parse(JSON.stringify(answers)),{answers:{color:{answers:['Green']},secret:{answers:['private-qa-answer']}}});
|
|
92
|
+
assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('private-qa-answer')),false);assert.equal(JSON.stringify(await api('GET','/workers/native-qa/state')).includes('private-qa-answer'),false);check('choice/freeform/secret response shape is correct and answers are absent from events/storage');
|
|
93
|
+
const form=ask('mcp-form','mcpServer/elicitation/request',formParams);
|
|
94
|
+
await p1.locator('#nativeRead').click();await p1.locator('#nativeApprovals [data-mcp-field="0"]').waitFor();
|
|
95
|
+
assert.equal(await p1.locator('#nativeApprovals script').count(),0);assert.ok((await p1.locator('#nativeApprovals').innerText()).includes('Project <name>'));
|
|
96
|
+
await p1.locator('#nativeApprovals [data-mcp-field="0"]').fill('private-ui-form');await p1.locator('#nativeApprovals [data-mcp-field="1"]').fill('0');
|
|
97
|
+
await p1.locator('#nativeApprovals [data-mcp-field="2"]').selectOption('1');await p1.locator('#nativeApprovals [data-mcp-field="3"]').selectOption('0');await p1.locator('#nativeApprovals [data-mcp-field="4"][data-mcp-option="1"]').check();
|
|
98
|
+
await p1.locator('#nativeApprovals button[data-decision="accept"]').click();await p1.waitForFunction(()=>document.getElementById('nativeNotice').textContent.includes('Invalid value'));assert.equal(interactions.snapshot().length,1);check('MCP invalid values stay pending and malicious schema labels remain text');
|
|
99
|
+
await p1.locator('#nativeApprovals [data-mcp-field="1"]').fill('2');await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals [data-mcp-field="0"]').inputValue(),'private-ui-form');assert.equal(await p1.locator('#nativeApprovals [data-mcp-field="4"][data-mcp-option="1"]').isChecked(),true);check('MCP text and multiselect drafts survive state refresh in page memory');
|
|
100
|
+
await p1.evaluate(()=>{document.querySelector('.native-scroll').scrollTop=0;});await shot(p1,'native-mcp-form-desktop');await p1.setViewportSize({width:390,height:844});assert.equal(await p1.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),true);await p1.evaluate(()=>{document.querySelector('.native-scroll').scrollTop=0;});await shot(p1,'native-mcp-form-mobile');await p1.locator('#nativeApprovals button[data-decision="accept"]').scrollIntoViewIfNeeded();await shot(p1,'native-mcp-form-mobile-submit');check('MCP form controls fit desktop and mobile viewports');
|
|
101
|
+
await p1.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await form,{action:'accept',content:{name:'private-ui-form',count:2,enabled:false,region:'eu',features:['tests']}});
|
|
102
|
+
assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('private-ui-form')),false);assert.equal(JSON.stringify(await api('GET','/workers/native-qa/state')).includes('private-ui-form'),false);check('MCP typed response reaches the original native request, optional defaults are omitted and answers are not retained');await p1.setViewportSize({width:1440,height:1000});
|
|
103
|
+
const unsupported=ask('unsupported-url','mcpServer/elicitation/request',{mode:'url',serverName:'form-fixture',message:'Authenticate',url:'https://auth.example.invalid'});await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p1.locator('#nativeApprovals button[data-decision="cancel"]').click();assert.deepEqual(await unsupported,{action:'cancel'});check('MCP URL request missing its elicitation identity has blocked acceptance and usable cancellation');
|
|
104
|
+
const malformed=ask('unsupported-schema','mcpServer/elicitation/request',{...formParams,requestedSchema:{type:'object',properties:{nested:{type:'object'}}}});await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p1.locator('#nativeApprovals button[data-decision="decline"]').click();assert.deepEqual(await malformed,{action:'decline'});check('unsupported MCP schema remains explicitly rejectable');
|
|
105
|
+
const takeover=ask('takeover','mcpServer/elicitation/request',{mode:'form',requestedSchema:{type:'object',properties:{enabled:{type:'boolean'}},required:['enabled']}});await p1.locator('#nativeRead').click();await p2.locator('#nativeRead').click();await p2.locator('#claimControlBtn').click();await wait(p2,()=>window.hccHandoff.canControl);await wait(p1,()=>!window.hccHandoff.canControl);
|
|
106
|
+
assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p2.locator('#nativeApprovals [data-mcp-field="0"]').selectOption('0');await p2.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await takeover,{action:'accept',content:{enabled:true}});check('lease takeover fences the old browser and enables the new controller');
|
|
107
|
+
const mobile=ask('mobile','item/permissions/requestApproval',{permissions:{network:{enabled:true},fileSystem:{read:['/long/path/with-a-readable-file-name'],write:['/work']}}});
|
|
108
|
+
await p2.setViewportSize({width:390,height:844});await p2.locator('#nativeRead').click();await p2.locator('#nativeApprovals [data-permission="read"]').waitFor();
|
|
109
|
+
assert.equal(await p2.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),true);await p2.evaluate(()=>{const el=document.querySelector('.header-controls');el.scrollLeft=el.scrollWidth;});assert.equal(await p2.evaluate(()=>document.getElementById('logoutBtn').getBoundingClientRect().right<=innerWidth),true);await shot(p2,'native-permissions-mobile');await p2.locator('#nativeApprovals button[data-decision="decline"]').click();assert.deepEqual(await mobile,{permissions:{},scope:'turn'});check('mobile permission controls wrap without page overflow and can decline');
|
|
110
|
+
await p1.locator('#openHistoryDialog').click();await p1.locator('#historyThreads [data-thread="qa-thread"]').waitFor();await p1.locator('#historyThreads [data-thread="qa-thread"]').click();await p1.locator('#historyConfirmed').check();await p1.locator('#historyResume').click();await wait(p1,()=>window.hccHandoff.canControl&&window.hccHandoff.session?.type==='app-server');
|
|
111
|
+
await p1.locator('#codexDraft').fill('test interactive permissions');await p1.locator('#codexSend').click();await p1.locator('#codexApprovals [data-permission="network"]').waitFor();await p1.locator('#codexApprovals [data-permission="network"]').check();await shot(p1,'codex-permissions-desktop');await p1.locator('#codexApprovals button[data-decision="accept"]').click();
|
|
112
|
+
await p1.locator('#codexApprovals [data-question="0"]').waitFor();await p1.locator('#codexApprovals [data-question="0"]').selectOption('0');await shot(p1,'codex-question-desktop');await p1.locator('#codexApprovals button[data-decision="accept"]').click();check('Web-owned App Server permissions and userInput use the same form and actual RPC replies');
|
|
113
|
+
await p1.locator('#codexApprovals [data-mcp-field="0"]').waitFor();await p1.locator('#codexApprovals [data-mcp-field="0"]').fill('web-owned-form');await p1.locator('#codexApprovals [data-mcp-field="1"]').fill('3');await p1.locator('#codexApprovals [data-mcp-field="2"]').selectOption('1');await p1.locator('#codexApprovals [data-mcp-field="3"]').selectOption('1');await p1.locator('#codexApprovals [data-mcp-field="4"][data-mcp-option="0"]').check();await shot(p1,'web-owned-mcp-form-desktop');await p1.locator('#codexApprovals button[data-decision="accept"]').click();
|
|
114
|
+
await p1.waitForFunction(()=>document.querySelector('#codexApprovals button[data-decision="cancel"]')&&document.querySelector('#codexApprovals button[data-decision="accept"]')?.disabled);await p1.locator('#codexApprovals button[data-decision="cancel"]').click();await wait(p1,()=>!document.querySelector('#codexApprovals button'));
|
|
115
|
+
const replies=fs.readFileSync(env.HCC_TEST_MCP_RPC,'utf8').trim().split('\n').map(JSON.parse);assert.deepEqual(replies.find(reply=>reply.id===0).result,{action:'accept',content:{name:'web-owned-form',count:3,enabled:false,region:'us',features:['logs']}});assert.deepEqual(replies.find(reply=>reply.id==='mcp-url').result,{action:'cancel'});check('Web-owned MCP forms return typed content to exact RPC id zero and URL mode stays cancellable');
|
|
116
|
+
assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('web-owned-form')),false);check('Web-owned MCP response is absent from browser storage');
|
|
117
|
+
assert.deepEqual(evidence.pageErrors,[]);assert.deepEqual(evidence.consoleErrors,[]);check('zero page errors or console warnings/errors');evidence.success=true;
|
|
118
|
+
}catch(error){evidence.success=false;evidence.error=error.message;console.error(error);if(browser)for(const context of browser.contexts())for(const page of context.pages())try{await shot(page,'failure-'+browser.contexts().indexOf(context));}catch{}}
|
|
119
|
+
finally{evidence.finishedAt=new Date().toISOString();const after=sourceHashes();evidence.sourceChangesDuringValidation=[...new Set([...Object.keys(evidence.sourceFiles),...Object.keys(after)])].filter(file=>after[file]!==evidence.sourceFiles[file]);if(evidence.sourceChangesDuringValidation.length){evidence.success=false;evidence.error='Source changed during browser validation';}await browser?.close();if(runtime)try{hcc('down');}catch(e){evidence.cleanupError=e.message;}await service?.shutdown();spawnSync(path.join(bin,'tmux'),['kill-server'],{env,stdio:'ignore'});fs.writeFileSync(path.join(dir,'evidence.json'),JSON.stringify(evidence,null,2));fs.writeFileSync('/tmp/hcc-mcp-form-ui-latest.json',JSON.stringify(evidence,null,2));for(const folder of [root,home,bin])fs.rmSync(folder,{recursive:true,force:true});console.log('EVIDENCE '+dir);if(!evidence.success)process.exitCode=1;}
|