@logicseek/hello-cc 0.1.6 → 0.1.8
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 +104 -0
- package/README.md +33 -9
- package/README.zh-CN.md +31 -9
- package/assets/screenshots/web-console.png +0 -0
- package/bin/hcc.mjs +749 -149
- package/docs/commands.md +13 -6
- package/docs/commands.zh-CN.md +11 -6
- package/docs/guide.md +10 -5
- package/docs/guide.zh-CN.md +8 -5
- package/lib/cli-runtime.mjs +2 -1
- package/lib/core/coordination/tasks.mjs +17 -1
- package/lib/core/coordination/timeline.mjs +11 -1
- package/lib/core/peers/identity.mjs +10 -2
- package/lib/core/sessions/launch.mjs +1 -0
- package/lib/integrations/shims/script.mjs +42 -22
- package/lib/integrations/shims.mjs +45 -6
- package/lib/setup.mjs +1 -0
- package/lib/shell-path.mjs +31 -16
- package/lib/ui/help.mjs +36 -1
- package/lib/web/peer-actions.mjs +42 -6
- package/lib/web/ui-template.mjs +5 -3
- package/package.json +1 -1
- package/scripts/regression.mjs +821 -97
package/scripts/regression.mjs
CHANGED
|
@@ -22,6 +22,9 @@ const fakeBin = path.join(os.tmpdir(), `hcc-reg-bin-${testId}`);
|
|
|
22
22
|
const outDir = path.join(os.tmpdir(), `hcc-reg-out-${testId}`);
|
|
23
23
|
const tmuxSession = `hcc-reg-${process.pid}`;
|
|
24
24
|
const tmuxSocketName = `hcc-reg-${testId}`.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
25
|
+
const secondProjectRoot = path.join(root, 'second-project');
|
|
26
|
+
const realHome = process.env.HOME || os.homedir();
|
|
27
|
+
const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
|
|
25
28
|
const realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
|
|
26
29
|
encoding: 'utf8',
|
|
27
30
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
@@ -35,7 +38,8 @@ const managedTmuxSessions = new Set();
|
|
|
35
38
|
const env = {
|
|
36
39
|
...process.env,
|
|
37
40
|
HOME: home,
|
|
38
|
-
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}
|
|
41
|
+
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
|
|
42
|
+
SHELL: '/bin/bash'
|
|
39
43
|
};
|
|
40
44
|
for (const key of Object.keys(env)) {
|
|
41
45
|
if (key.startsWith('HCC_')) delete env[key];
|
|
@@ -48,6 +52,13 @@ function log(message) {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
function fail(message) {
|
|
55
|
+
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
56
|
+
const escaped = String(message)
|
|
57
|
+
.replace(/%/g, '%25')
|
|
58
|
+
.replace(/\r/g, '%0D')
|
|
59
|
+
.replace(/\n/g, '%0A');
|
|
60
|
+
process.stderr.write(`::error::${escaped}\n`);
|
|
61
|
+
}
|
|
51
62
|
throw new Error(message);
|
|
52
63
|
}
|
|
53
64
|
|
|
@@ -59,6 +70,20 @@ function sh(value) {
|
|
|
59
70
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
function canonicalPath(value) {
|
|
74
|
+
try { return fs.realpathSync(value); }
|
|
75
|
+
catch { return path.resolve(value); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function samePath(a, b) {
|
|
79
|
+
return canonicalPath(a) === canonicalPath(b);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statusValue(output, key) {
|
|
83
|
+
const match = String(output || '').match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
|
|
84
|
+
return match ? match[1].trim() : '';
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
function shortHash(value) {
|
|
63
88
|
return crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
|
|
64
89
|
}
|
|
@@ -72,6 +97,10 @@ function tmuxManagedSession(projectRoot, peer) {
|
|
|
72
97
|
return `hcc-${shortHash(projectRoot)}-${sanitizePeerPart(peer, 'peer')}`.slice(0, 80);
|
|
73
98
|
}
|
|
74
99
|
|
|
100
|
+
function tmuxManagedSessionPrefix(projectRoot) {
|
|
101
|
+
return `hcc-${shortHash(projectRoot)}-`;
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
function run(command, args, options = {}) {
|
|
76
105
|
const result = spawnSync(command, args, {
|
|
77
106
|
cwd: options.cwd || repoRoot,
|
|
@@ -140,6 +169,33 @@ function providerBindingRows(provider, sessionName) {
|
|
|
140
169
|
`).all(provider, sessionName));
|
|
141
170
|
}
|
|
142
171
|
|
|
172
|
+
function parsePayloadJson(value) {
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(value || '{}');
|
|
175
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
176
|
+
} catch {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function eventPayloads(type, limit = 20, dbPath = path.join(root, '.hello-cc', 'mesh.db')) {
|
|
182
|
+
const db = new DatabaseSync(dbPath);
|
|
183
|
+
try {
|
|
184
|
+
return db.prepare(`
|
|
185
|
+
SELECT actor, payload
|
|
186
|
+
FROM events
|
|
187
|
+
WHERE type = ?
|
|
188
|
+
ORDER BY id DESC
|
|
189
|
+
LIMIT ?
|
|
190
|
+
`).all(type, limit).map((row) => ({
|
|
191
|
+
actor: row.actor,
|
|
192
|
+
payload: parsePayloadJson(row.payload)
|
|
193
|
+
}));
|
|
194
|
+
} finally {
|
|
195
|
+
db.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
143
199
|
function peerBindingIndexNames(dbPath = path.join(root, '.hello-cc', 'mesh.db')) {
|
|
144
200
|
const db = new DatabaseSync(dbPath);
|
|
145
201
|
try {
|
|
@@ -366,7 +422,8 @@ async function assertTmuxGcPolicy() {
|
|
|
366
422
|
}), t);
|
|
367
423
|
});
|
|
368
424
|
|
|
369
|
-
const
|
|
425
|
+
const attachTmux = realTmuxBin ? `${sh(realTmuxBin)} -L ${sh(tmuxSocketName)}` : 'tmux';
|
|
426
|
+
const client = spawnSync('tmux', ['new-session', '-d', '-s', `${tmuxSession}-gc-client`, '-e', `HCC_ROOT=${root}`, 'sh', '-lc', `unset TMUX; exec ${attachTmux} attach-session -t ${sh(attachedSession)}`], {
|
|
370
427
|
env,
|
|
371
428
|
encoding: 'utf8',
|
|
372
429
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
@@ -773,6 +830,48 @@ function tmuxAvailable() {
|
|
|
773
830
|
return runMaybe('tmux', ['-V']).status === 0;
|
|
774
831
|
}
|
|
775
832
|
|
|
833
|
+
function tmuxCleanupEndpoints() {
|
|
834
|
+
if (!realTmuxBin) return [{ label: 'default', command: 'tmux', args: [] }];
|
|
835
|
+
return [
|
|
836
|
+
{ label: 'test-socket', command: realTmuxBin, args: ['-L', tmuxSocketName] },
|
|
837
|
+
{ label: 'default', command: realTmuxBin, args: [] }
|
|
838
|
+
];
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function runMaybeTmuxEndpoint(endpoint, args) {
|
|
842
|
+
return runMaybe(endpoint.command, [...endpoint.args, ...args]);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function listTmuxSessionsMatching(prefixes, endpoint) {
|
|
846
|
+
const output = runMaybeTmuxEndpoint(endpoint, ['list-sessions', '-F', '#{session_name}']);
|
|
847
|
+
if (output.status !== 0) return [];
|
|
848
|
+
return (output.stdout || '')
|
|
849
|
+
.split('\n')
|
|
850
|
+
.map((line) => line.trim())
|
|
851
|
+
.filter(Boolean)
|
|
852
|
+
.filter((session) => prefixes.some((prefix) => session.startsWith(prefix)));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function killTmuxSessionOnEndpoint(endpoint, session) {
|
|
856
|
+
runMaybeTmuxEndpoint(endpoint, ['kill-session', '-t', session]);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function assertNoRegressionTmuxSessionLeak() {
|
|
860
|
+
const prefixes = [
|
|
861
|
+
tmuxManagedSessionPrefix(root),
|
|
862
|
+
tmuxManagedSessionPrefix(secondProjectRoot)
|
|
863
|
+
];
|
|
864
|
+
const leaks = [];
|
|
865
|
+
for (const endpoint of tmuxCleanupEndpoints()) {
|
|
866
|
+
for (const session of listTmuxSessionsMatching(prefixes, endpoint)) {
|
|
867
|
+
leaks.push(`${endpoint.label}:${session}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (leaks.length) {
|
|
871
|
+
fail(`regression leaked hcc-managed tmux sessions:\n${leaks.join('\n')}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
776
875
|
function trackTmuxPane(pane) {
|
|
777
876
|
if (!pane) return;
|
|
778
877
|
const result = runMaybe('tmux', ['display-message', '-p', '-t', pane, '#S']);
|
|
@@ -947,19 +1046,34 @@ async function waitForFile(file, expected, label = file) {
|
|
|
947
1046
|
}
|
|
948
1047
|
|
|
949
1048
|
async function waitForFileContent(file, expected, label = file) {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1049
|
+
const deadline = Date.now() + 10000;
|
|
1050
|
+
let actual = '(missing)';
|
|
1051
|
+
while (Date.now() < deadline) {
|
|
1052
|
+
if (fs.existsSync(file)) {
|
|
1053
|
+
actual = fs.readFileSync(file, 'utf8').trim();
|
|
1054
|
+
if (actual === expected) break;
|
|
1055
|
+
}
|
|
1056
|
+
await sleep(100);
|
|
1057
|
+
}
|
|
1058
|
+
if (actual !== expected) {
|
|
1059
|
+
fail(`timed out waiting for ${label}\nexpected: ${expected}\nactual: ${actual}`);
|
|
1060
|
+
}
|
|
954
1061
|
ensureFile(file, expected);
|
|
955
1062
|
}
|
|
956
1063
|
|
|
957
1064
|
async function waitForFileLineCount(file, expected, label = file) {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1065
|
+
const deadline = Date.now() + 10000;
|
|
1066
|
+
let actual = '(missing)';
|
|
1067
|
+
while (Date.now() < deadline) {
|
|
1068
|
+
if (fs.existsSync(file)) {
|
|
1069
|
+
actual = fs.readFileSync(file, 'utf8').trim();
|
|
1070
|
+
const lines = actual.split('\n').filter(Boolean);
|
|
1071
|
+
if (lines.length === expected) return;
|
|
1072
|
+
}
|
|
1073
|
+
await sleep(100);
|
|
1074
|
+
}
|
|
1075
|
+
const count = actual === '(missing)' ? 0 : actual.split('\n').filter(Boolean).length;
|
|
1076
|
+
fail(`timed out waiting for ${label}\nexpected lines: ${expected}\nactual lines: ${count}\nactual: ${actual}`);
|
|
963
1077
|
}
|
|
964
1078
|
|
|
965
1079
|
async function waitRuntime() {
|
|
@@ -975,6 +1089,19 @@ async function waitRuntime() {
|
|
|
975
1089
|
}, 'runtime');
|
|
976
1090
|
}
|
|
977
1091
|
|
|
1092
|
+
function processIsAlive(pid) {
|
|
1093
|
+
try {
|
|
1094
|
+
process.kill(pid, 0);
|
|
1095
|
+
return true;
|
|
1096
|
+
} catch {
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function waitForProcessExit(pid, label, timeoutMs = 5000) {
|
|
1102
|
+
await waitFor(() => !processIsAlive(pid), label, timeoutMs);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
978
1105
|
async function expectWebSocketMarker(peer, marker) {
|
|
979
1106
|
await new Promise((resolve, reject) => {
|
|
980
1107
|
let sawSnapshot = false;
|
|
@@ -1001,6 +1128,21 @@ async function expectWebSocketMarker(peer, marker) {
|
|
|
1001
1128
|
});
|
|
1002
1129
|
}
|
|
1003
1130
|
|
|
1131
|
+
async function openTerminalWebSocket(peer) {
|
|
1132
|
+
return new Promise((resolve, reject) => {
|
|
1133
|
+
const ws = new WebSocket(runtimeWsUrl(peer));
|
|
1134
|
+
const timer = setTimeout(() => reject(new Error(`${peer} websocket open timeout`)), 5000);
|
|
1135
|
+
ws.on('open', () => {
|
|
1136
|
+
clearTimeout(timer);
|
|
1137
|
+
resolve(ws);
|
|
1138
|
+
});
|
|
1139
|
+
ws.on('error', (err) => {
|
|
1140
|
+
clearTimeout(timer);
|
|
1141
|
+
reject(err);
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1004
1146
|
async function expectResizeReplaceSnapshot(peer, marker) {
|
|
1005
1147
|
await new Promise((resolve, reject) => {
|
|
1006
1148
|
let sawSnapshot = false;
|
|
@@ -1120,17 +1262,20 @@ function startRuntime(options = {}) {
|
|
|
1120
1262
|
|
|
1121
1263
|
async function stopRuntime() {
|
|
1122
1264
|
if (!runtimePid) return;
|
|
1265
|
+
const pid = runtimePid;
|
|
1123
1266
|
hccMaybe(['down']);
|
|
1124
|
-
|
|
1267
|
+
try {
|
|
1268
|
+
await waitForProcessExit(pid, 'runtime process exit', 5000);
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
1125
1271
|
try {
|
|
1126
|
-
|
|
1127
|
-
return false;
|
|
1272
|
+
await waitForProcessExit(pid, 'runtime process exit after SIGTERM', 2000);
|
|
1128
1273
|
} catch {
|
|
1129
|
-
|
|
1274
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
1275
|
+
try { await waitForProcessExit(pid, 'runtime process exit after SIGKILL', 2000); } catch {}
|
|
1276
|
+
throw err;
|
|
1130
1277
|
}
|
|
1131
|
-
}
|
|
1132
|
-
try { process.kill(runtimePid, 'SIGTERM'); } catch {}
|
|
1133
|
-
});
|
|
1278
|
+
}
|
|
1134
1279
|
runtimePid = null;
|
|
1135
1280
|
}
|
|
1136
1281
|
|
|
@@ -1139,11 +1284,20 @@ function cleanup() {
|
|
|
1139
1284
|
if (runtimePid) {
|
|
1140
1285
|
try { process.kill(runtimePid, 'SIGTERM'); } catch {}
|
|
1141
1286
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1287
|
+
const managedPrefixes = [
|
|
1288
|
+
tmuxManagedSessionPrefix(root),
|
|
1289
|
+
tmuxManagedSessionPrefix(secondProjectRoot)
|
|
1290
|
+
];
|
|
1291
|
+
for (const endpoint of tmuxCleanupEndpoints()) {
|
|
1292
|
+
for (const session of listTmuxSessionsMatching(managedPrefixes, endpoint)) {
|
|
1293
|
+
managedTmuxSessions.add(session);
|
|
1294
|
+
}
|
|
1295
|
+
if (tmuxStarted) {
|
|
1296
|
+
killTmuxSessionOnEndpoint(endpoint, tmuxSession);
|
|
1297
|
+
}
|
|
1298
|
+
for (const session of managedTmuxSessions) {
|
|
1299
|
+
killTmuxSessionOnEndpoint(endpoint, session);
|
|
1300
|
+
}
|
|
1147
1301
|
}
|
|
1148
1302
|
if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
|
|
1149
1303
|
runMaybe('tmux', ['kill-server']);
|
|
@@ -1153,8 +1307,36 @@ function cleanup() {
|
|
|
1153
1307
|
}
|
|
1154
1308
|
}
|
|
1155
1309
|
|
|
1310
|
+
function assertNoRealProjectRegistryLeak() {
|
|
1311
|
+
const after = fs.existsSync(realRegistryFile)
|
|
1312
|
+
? fs.readFileSync(realRegistryFile, 'utf8')
|
|
1313
|
+
: null;
|
|
1314
|
+
if (!after) return;
|
|
1315
|
+
|
|
1316
|
+
let parsed = null;
|
|
1317
|
+
try {
|
|
1318
|
+
parsed = JSON.parse(after);
|
|
1319
|
+
} catch {
|
|
1320
|
+
fail(`regression changed real project registry with invalid JSON: ${realRegistryFile}`);
|
|
1321
|
+
}
|
|
1322
|
+
const leakedRoots = (parsed?.projects || [])
|
|
1323
|
+
.map((project) => String(project?.root || ''))
|
|
1324
|
+
.filter((projectRoot) => {
|
|
1325
|
+
if (!projectRoot) return false;
|
|
1326
|
+
if (projectRoot === root || projectRoot === home || projectRoot === fakeBin || projectRoot === outDir) return true;
|
|
1327
|
+
return projectRoot.startsWith(`${root}${path.sep}`) ||
|
|
1328
|
+
projectRoot.startsWith(`${home}${path.sep}`) ||
|
|
1329
|
+
projectRoot.startsWith(`${fakeBin}${path.sep}`) ||
|
|
1330
|
+
projectRoot.startsWith(`${outDir}${path.sep}`) ||
|
|
1331
|
+
projectRoot.includes(`hcc-reg-${testId}`);
|
|
1332
|
+
});
|
|
1333
|
+
if (leakedRoots.length) {
|
|
1334
|
+
fail(`regression leaked temporary projects into real registry ${realRegistryFile}:\n${leakedRoots.join('\n')}`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1156
1338
|
async function setupRegression() {
|
|
1157
|
-
log('[1/
|
|
1339
|
+
log('[1/13] web bootstrap/hooks/shims');
|
|
1158
1340
|
writeFakeTools();
|
|
1159
1341
|
fs.mkdirSync(root, { recursive: true });
|
|
1160
1342
|
fs.mkdirSync(home, { recursive: true });
|
|
@@ -1202,7 +1384,12 @@ async function setupRegression() {
|
|
|
1202
1384
|
fail(`interactive bash did not keep codex shim first: ${interactiveCodex}`);
|
|
1203
1385
|
}
|
|
1204
1386
|
if (!hcc(['install-hooks', '--status']).includes('claude=yes codex=yes')) fail('hooks not installed');
|
|
1205
|
-
|
|
1387
|
+
const shimStatusOutput = hcc(['shim', 'status']);
|
|
1388
|
+
if (!shimStatusOutput.includes('claude: installed') ||
|
|
1389
|
+
!shimStatusOutput.includes('codex: installed') ||
|
|
1390
|
+
!shimStatusOutput.includes('status: complete')) {
|
|
1391
|
+
fail(`shims not fully installed:\n${shimStatusOutput}`);
|
|
1392
|
+
}
|
|
1206
1393
|
const staleShim = path.join(home, '.hcc-shims', 'claude');
|
|
1207
1394
|
fs.writeFileSync(staleShim, '#!/usr/bin/env bash\necho stale --web-managed\n', { mode: 0o755 });
|
|
1208
1395
|
const ensured = hccMaybe(['shim', 'ensure', 'claude', staleShim]);
|
|
@@ -1213,6 +1400,25 @@ async function setupRegression() {
|
|
|
1213
1400
|
if (!refreshedShim.includes('shim ensure "claude"') || refreshedShim.includes('stale --web-managed')) {
|
|
1214
1401
|
fail(`shim ensure did not refresh stale shim:\n${refreshedShim}`);
|
|
1215
1402
|
}
|
|
1403
|
+
const deadRealBin = path.join(outDir, 'removed-claude');
|
|
1404
|
+
fs.rmSync(deadRealBin, { force: true });
|
|
1405
|
+
fs.writeFileSync(staleShim, [
|
|
1406
|
+
'#!/usr/bin/env bash',
|
|
1407
|
+
'# hello-cc shim for claude (auto-generated; do not edit manually)',
|
|
1408
|
+
`# Real binary: ${deadRealBin}`,
|
|
1409
|
+
`REAL_BIN=${sh(deadRealBin)}`,
|
|
1410
|
+
'exec "$REAL_BIN" "$@"',
|
|
1411
|
+
''
|
|
1412
|
+
].join('\n'), { mode: 0o755 });
|
|
1413
|
+
const rediscovered = hccMaybe(['shim', 'ensure', 'claude', staleShim, deadRealBin]);
|
|
1414
|
+
if (rediscovered.status !== 75) {
|
|
1415
|
+
fail(`shim ensure did not request re-exec for stale real binary:\n${rediscovered.stdout}\n${rediscovered.stderr}`);
|
|
1416
|
+
}
|
|
1417
|
+
const rediscoveredShim = fs.readFileSync(staleShim, 'utf8');
|
|
1418
|
+
const expectedRealBin = path.join(fakeBin, 'claude');
|
|
1419
|
+
if (!rediscoveredShim.includes(`# Real binary: ${expectedRealBin}`) || rediscoveredShim.includes(deadRealBin)) {
|
|
1420
|
+
fail(`shim ensure preserved stale real binary instead of rediscovering PATH binary:\n${rediscoveredShim}`);
|
|
1421
|
+
}
|
|
1216
1422
|
await stopRuntime();
|
|
1217
1423
|
assertPeerBindingUniqueConstraints();
|
|
1218
1424
|
assertLegacySchemaMigration();
|
|
@@ -1304,16 +1510,18 @@ async function setupRegression() {
|
|
|
1304
1510
|
const childDir = path.join(root, 'packages', 'child');
|
|
1305
1511
|
fs.mkdirSync(childDir, { recursive: true });
|
|
1306
1512
|
const childFindRoot = hccFrom(['find-root'], childDir).trim();
|
|
1307
|
-
if (childFindRoot
|
|
1513
|
+
if (!samePath(childFindRoot, childDir)) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
|
|
1308
1514
|
const childStatus = hccFrom(['status'], childDir);
|
|
1309
|
-
if (!
|
|
1515
|
+
if (!samePath(statusValue(childStatus, 'root'), childDir) ||
|
|
1516
|
+
!samePath(statusValue(childStatus, 'db'), path.join(childDir, '.hello-cc', 'mesh.db'))) {
|
|
1310
1517
|
fail(`child command did not stay on current path:\n${childStatus}`);
|
|
1311
1518
|
}
|
|
1312
1519
|
ensureFile(path.join(childDir, '.hello-cc', 'mesh.db'));
|
|
1313
1520
|
const explicitChildRoot = hccFrom(['--root', root, 'find-root'], childDir).trim();
|
|
1314
|
-
if (explicitChildRoot
|
|
1521
|
+
if (!samePath(explicitChildRoot, root)) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
|
|
1315
1522
|
const explicitChildStatus = hccFrom(['--root', root, 'status'], childDir);
|
|
1316
|
-
if (!
|
|
1523
|
+
if (!samePath(statusValue(explicitChildStatus, 'root'), root) ||
|
|
1524
|
+
!samePath(statusValue(explicitChildStatus, 'db'), path.join(root, '.hello-cc', 'mesh.db'))) {
|
|
1317
1525
|
fail(`explicit child command did not use requested root:\n${explicitChildStatus}`);
|
|
1318
1526
|
}
|
|
1319
1527
|
const childHookPayload = JSON.stringify({ session_id: 'child-hook-session', cwd: childDir, hook_event_name: 'UserPromptSubmit', prompt: 'status?' });
|
|
@@ -1338,7 +1546,7 @@ async function setupRegression() {
|
|
|
1338
1546
|
}
|
|
1339
1547
|
|
|
1340
1548
|
async function dbWorkflow() {
|
|
1341
|
-
log('[3/
|
|
1549
|
+
log('[3/13] db workflow');
|
|
1342
1550
|
hcc(['register', '--peer', 'human', '--kind', 'human', '--role', 'operator']);
|
|
1343
1551
|
const created = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'full regression task', '--body', 'exercise hcc bus']);
|
|
1344
1552
|
const taskMatch = created.match(/created task #(\d+):/);
|
|
@@ -1452,6 +1660,38 @@ async function dbWorkflow() {
|
|
|
1452
1660
|
hcc(['task', 'done', '--peer', 'batch-b', '--id', String(task.id), '--summary', 'batch next cleanup']);
|
|
1453
1661
|
}
|
|
1454
1662
|
hcc(['task', 'update', '--peer', 'human', '--id', batchNextIds[2], '--status', 'abandoned', '--summary', 'batch next leftover cleanup']);
|
|
1663
|
+
|
|
1664
|
+
const dispatched = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'dispatch-a', '--title', 'dispatch new task', '--body', 'dispatch body', '--no-inject']);
|
|
1665
|
+
if (!dispatched.task || dispatched.task.assignee !== 'dispatch-a' || dispatched.task.owner ||
|
|
1666
|
+
dispatched.injected !== false || dispatched.injection_reason !== 'no_inject' || !dispatched.message_id) {
|
|
1667
|
+
fail(`task dispatch did not create message-only assigned task:\n${JSON.stringify(dispatched, null, 2)}`);
|
|
1668
|
+
}
|
|
1669
|
+
const dispatchInbox = hcc(['msg', 'inbox', '--peer', 'dispatch-a', '--all']);
|
|
1670
|
+
if (!dispatchInbox.includes(`task #${dispatched.task.id}`) || !dispatchInbox.includes('dispatch new task')) {
|
|
1671
|
+
fail(`task dispatch durable message missing:\n${dispatchInbox}`);
|
|
1672
|
+
}
|
|
1673
|
+
const dispatchAudit = eventPayloads('task.dispatched', 20)
|
|
1674
|
+
.find((event) => Number(event.payload?.message_id) === Number(dispatched.message_id));
|
|
1675
|
+
if (!dispatchAudit ||
|
|
1676
|
+
dispatchAudit.actor !== 'human' ||
|
|
1677
|
+
dispatchAudit.payload.actor_peer !== 'human' ||
|
|
1678
|
+
dispatchAudit.payload.target_peer !== 'dispatch-a' ||
|
|
1679
|
+
dispatchAudit.payload.source !== 'cli' ||
|
|
1680
|
+
dispatchAudit.payload.injected !== false ||
|
|
1681
|
+
dispatchAudit.payload.injection_reason !== 'no_inject') {
|
|
1682
|
+
fail(`task dispatch audit payload wrong:\n${JSON.stringify(dispatchAudit, null, 2)}`);
|
|
1683
|
+
}
|
|
1684
|
+
const dispatchExistingCreated = hcc(['task', 'create', '--from', 'human', '--title', 'dispatch existing task']);
|
|
1685
|
+
const dispatchExistingMatch = dispatchExistingCreated.match(/created task #(\d+):/);
|
|
1686
|
+
if (!dispatchExistingMatch) fail(`cannot parse dispatch existing task id: ${dispatchExistingCreated}`);
|
|
1687
|
+
const dispatchExisting = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'dispatch-b', '--id', dispatchExistingMatch[1], '--message', 'custom dispatch existing', '--no-inject']);
|
|
1688
|
+
if (String(dispatchExisting.task?.id) !== dispatchExistingMatch[1] ||
|
|
1689
|
+
dispatchExisting.task.assignee !== 'dispatch-b' ||
|
|
1690
|
+
dispatchExisting.message !== 'custom dispatch existing' ||
|
|
1691
|
+
dispatchExisting.injected !== false) {
|
|
1692
|
+
fail(`task dispatch --id did not assign existing task:\n${JSON.stringify(dispatchExisting, null, 2)}`);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1455
1695
|
const takeoverOpen = hcc(['task', 'create', '--from', 'human', '--to', 'takeover-owner', '--title', 'takeover open']);
|
|
1456
1696
|
const takeoverOpenMatch = takeoverOpen.match(/created task #(\d+):/);
|
|
1457
1697
|
if (!takeoverOpenMatch) fail(`cannot parse takeover open id: ${takeoverOpen}`);
|
|
@@ -1505,7 +1745,8 @@ async function dbWorkflow() {
|
|
|
1505
1745
|
!staleLivenessState.automation.next_action.argv.includes('stale')) {
|
|
1506
1746
|
fail(`state automation did not suggest stale takeover:\n${JSON.stringify(staleLivenessState.automation, null, 2)}`);
|
|
1507
1747
|
}
|
|
1508
|
-
hcc(['task', '
|
|
1748
|
+
hcc(['task', 'takeover', '--peer', 'human', '--id', staleLivenessTaskId, '--reason', 'stale liveness cleanup', '--policy', 'stale', '--stale-after', '60']);
|
|
1749
|
+
hcc(['task', 'update', '--peer', 'human', '--id', staleLivenessTaskId, '--status', 'abandoned', '--summary', 'stale liveness cleanup']);
|
|
1509
1750
|
hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
|
|
1510
1751
|
hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
|
|
1511
1752
|
if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
|
|
@@ -1727,13 +1968,14 @@ async function dbWorkflow() {
|
|
|
1727
1968
|
}
|
|
1728
1969
|
|
|
1729
1970
|
async function multiProjectWebWorkflow() {
|
|
1730
|
-
log('[4/
|
|
1731
|
-
const otherRoot =
|
|
1971
|
+
log('[4/13] multi-project web');
|
|
1972
|
+
const otherRoot = secondProjectRoot;
|
|
1732
1973
|
fs.mkdirSync(otherRoot, { recursive: true });
|
|
1733
1974
|
const output = hccFrom(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], otherRoot);
|
|
1734
1975
|
if (!output.includes('web already running in background')) fail(`second project did not reuse global web:\n${output}`);
|
|
1735
|
-
|
|
1736
|
-
if (!
|
|
1976
|
+
const outputRoot = statusValue(output, 'project');
|
|
1977
|
+
if (!samePath(outputRoot, otherRoot)) fail(`second project output did not show its root:\n${output}`);
|
|
1978
|
+
if (!output.includes(encodeURIComponent(outputRoot))) fail(`second project URL did not include project query:\n${output}`);
|
|
1737
1979
|
|
|
1738
1980
|
const otherRuntimeFile = path.join(otherRoot, '.hello-cc', 'runtime.json');
|
|
1739
1981
|
ensureFile(otherRuntimeFile);
|
|
@@ -1745,8 +1987,9 @@ async function multiProjectWebWorkflow() {
|
|
|
1745
1987
|
const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
|
|
1746
1988
|
const projects = await projectsResponse.json();
|
|
1747
1989
|
if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
|
|
1748
|
-
const roots =
|
|
1749
|
-
if (!roots.
|
|
1990
|
+
const roots = (projects.projects || []).map((p) => p.root);
|
|
1991
|
+
if (!roots.some((projectRoot) => samePath(projectRoot, root)) ||
|
|
1992
|
+
!roots.some((projectRoot) => samePath(projectRoot, otherRoot))) {
|
|
1750
1993
|
fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
|
|
1751
1994
|
}
|
|
1752
1995
|
|
|
@@ -1907,6 +2150,72 @@ async function multiProjectWebWorkflow() {
|
|
|
1907
2150
|
fail(`sessions API did not expose unknown provider binding without marking it shared/known:\n${JSON.stringify(otherShellSession, null, 2)}`);
|
|
1908
2151
|
}
|
|
1909
2152
|
|
|
2153
|
+
const startProvider = async (payload) => {
|
|
2154
|
+
const response = await runtimeFetch('/api/sessions', {
|
|
2155
|
+
method: 'POST',
|
|
2156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2157
|
+
body: JSON.stringify({
|
|
2158
|
+
env: {
|
|
2159
|
+
HOME: home,
|
|
2160
|
+
PATH: env.PATH,
|
|
2161
|
+
SHELL: process.env.SHELL || 'bash',
|
|
2162
|
+
HCC_FAKE_STAY_ALIVE: '1'
|
|
2163
|
+
},
|
|
2164
|
+
...payload
|
|
2165
|
+
})
|
|
2166
|
+
}, { root });
|
|
2167
|
+
const json = await response.json();
|
|
2168
|
+
if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
|
|
2169
|
+
return json.session;
|
|
2170
|
+
};
|
|
2171
|
+
const stopSession = async (id, payload = null) => {
|
|
2172
|
+
const options = payload
|
|
2173
|
+
? {
|
|
2174
|
+
method: 'POST',
|
|
2175
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2176
|
+
body: JSON.stringify(payload)
|
|
2177
|
+
}
|
|
2178
|
+
: { method: 'POST' };
|
|
2179
|
+
const response = await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, options, { root });
|
|
2180
|
+
const json = await response.json();
|
|
2181
|
+
if (!response.ok) fail(`web provider session stop failed: ${JSON.stringify(json)}`);
|
|
2182
|
+
return json.session;
|
|
2183
|
+
};
|
|
2184
|
+
|
|
2185
|
+
const auditSpoofSession = await startProvider({
|
|
2186
|
+
kind: 'shell',
|
|
2187
|
+
command: 'bash --noprofile --norc',
|
|
2188
|
+
auditActorPeer: 'spoofed-web-actor',
|
|
2189
|
+
auditSource: 'spoofed-source'
|
|
2190
|
+
});
|
|
2191
|
+
const auditSpoofPeer = auditSpoofSession.peer_id || auditSpoofSession.id;
|
|
2192
|
+
const attachAudit = eventPayloads('tmux.session.attached', 80)
|
|
2193
|
+
.find((event) => event.payload?.target_peer === auditSpoofPeer);
|
|
2194
|
+
if (!attachAudit ||
|
|
2195
|
+
attachAudit.actor !== 'web' ||
|
|
2196
|
+
attachAudit.payload.actor_peer !== 'web' ||
|
|
2197
|
+
attachAudit.payload.source !== 'web' ||
|
|
2198
|
+
attachAudit.payload.actor_peer === 'spoofed-web-actor' ||
|
|
2199
|
+
attachAudit.payload.source === 'spoofed-source') {
|
|
2200
|
+
fail(`web start audit allowed spoofed actor/source:\n${JSON.stringify({ auditSpoofSession, attachAudit }, null, 2)}`);
|
|
2201
|
+
}
|
|
2202
|
+
await stopSession(auditSpoofSession.id, {
|
|
2203
|
+
auditActorPeer: 'spoofed-stop-actor',
|
|
2204
|
+
auditSource: 'spoofed-source'
|
|
2205
|
+
});
|
|
2206
|
+
const stopAudit = eventPayloads('web.session.stop_requested', 80)
|
|
2207
|
+
.find((event) => event.payload?.target_peer === auditSpoofPeer);
|
|
2208
|
+
if (!stopAudit ||
|
|
2209
|
+
stopAudit.actor !== 'web' ||
|
|
2210
|
+
stopAudit.payload.actor_peer !== 'web' ||
|
|
2211
|
+
stopAudit.payload.target_peer !== auditSpoofPeer ||
|
|
2212
|
+
stopAudit.payload.source !== 'web' ||
|
|
2213
|
+
stopAudit.payload.admin !== true ||
|
|
2214
|
+
stopAudit.payload.actor_peer === 'spoofed-stop-actor' ||
|
|
2215
|
+
stopAudit.payload.source === 'spoofed-source') {
|
|
2216
|
+
fail(`web stop audit allowed spoofed actor/source:\n${JSON.stringify({ auditSpoofSession, stopAudit }, null, 2)}`);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
1910
2219
|
hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
|
|
1911
2220
|
hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
|
|
1912
2221
|
const actionStatus = await (await runtimeFetch('/api/peers/web-action-peer/actions/status', {}, { root })).json();
|
|
@@ -1924,10 +2233,28 @@ async function multiProjectWebWorkflow() {
|
|
|
1924
2233
|
const taskOutput = hcc(['task', 'create', '--title', 'web action task', '--body', 'claim through web action']);
|
|
1925
2234
|
const taskMatch = taskOutput.match(/created task #(\d+):/);
|
|
1926
2235
|
if (!taskMatch) fail(`cannot parse web action task id:\n${taskOutput}`);
|
|
1927
|
-
const
|
|
2236
|
+
const rejectedActionNextResponse = await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
|
|
1928
2237
|
method: 'POST',
|
|
1929
2238
|
headers: { 'Content-Type': 'application/json' },
|
|
1930
2239
|
body: '{}'
|
|
2240
|
+
}, { root });
|
|
2241
|
+
const rejectedActionNext = await rejectedActionNextResponse.json();
|
|
2242
|
+
if (rejectedActionNextResponse.ok || rejectedActionNext.error?.code !== 'PEER_IDENTITY_REQUIRED') {
|
|
2243
|
+
fail(`web task-next accepted unmanaged peer identity:\n${JSON.stringify(rejectedActionNext, null, 2)}`);
|
|
2244
|
+
}
|
|
2245
|
+
const unmanagedTaskAfterReject = hccJson(['task', 'list', '--all']).find((task) => String(task.id) === taskMatch[1]);
|
|
2246
|
+
if (!unmanagedTaskAfterReject || unmanagedTaskAfterReject.owner) {
|
|
2247
|
+
fail(`rejected web task-next still changed task owner:\n${JSON.stringify(unmanagedTaskAfterReject, null, 2)}`);
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
const managedActionSession = await startProvider({ kind: 'shell', command: 'bash --noprofile --norc' });
|
|
2251
|
+
if (!managedActionSession.action_token) {
|
|
2252
|
+
fail(`managed web session did not include action token:\n${JSON.stringify(managedActionSession, null, 2)}`);
|
|
2253
|
+
}
|
|
2254
|
+
const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
|
|
2255
|
+
method: 'POST',
|
|
2256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2257
|
+
body: JSON.stringify({ action_token: managedActionSession.action_token })
|
|
1931
2258
|
}, { root })).json();
|
|
1932
2259
|
if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
|
|
1933
2260
|
fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
|
|
@@ -1937,31 +2264,51 @@ async function multiProjectWebWorkflow() {
|
|
|
1937
2264
|
headers: { 'Content-Type': 'application/json' },
|
|
1938
2265
|
body: JSON.stringify({ renew_locks: true })
|
|
1939
2266
|
}, { root })).json();
|
|
1940
|
-
if (
|
|
1941
|
-
fail(`web heartbeat
|
|
2267
|
+
if (actionHeartbeat.ok || actionHeartbeat.error?.code !== 'PEER_IDENTITY_REQUIRED') {
|
|
2268
|
+
fail(`web heartbeat accepted unmanaged peer identity:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
|
|
1942
2269
|
}
|
|
1943
2270
|
|
|
1944
|
-
const
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
}
|
|
2271
|
+
const ownerTask = hcc(['task', 'create', '--from', 'web-lock-owner', '--title', 'web lock owner task']);
|
|
2272
|
+
const ownerTaskMatch = ownerTask.match(/created task #(\d+):/);
|
|
2273
|
+
if (!ownerTaskMatch) fail(`cannot parse web lock owner task id:\n${ownerTask}`);
|
|
2274
|
+
hcc(['task', 'claim', '--peer', 'web-lock-owner', '--id', ownerTaskMatch[1]]);
|
|
2275
|
+
const rejectedLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/lock-acquire`, {
|
|
2276
|
+
method: 'POST',
|
|
2277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2278
|
+
body: JSON.stringify({
|
|
2279
|
+
action_token: managedActionSession.action_token,
|
|
2280
|
+
resource: 'web/identity-spoof-lock',
|
|
2281
|
+
task: Number(ownerTaskMatch[1])
|
|
2282
|
+
})
|
|
2283
|
+
}, { root });
|
|
2284
|
+
const rejectedLock = await rejectedLockResponse.json();
|
|
2285
|
+
if (rejectedLockResponse.ok || rejectedLock.error?.code !== 'TASK_OWNED') {
|
|
2286
|
+
fail(`web lock-acquire did not enforce task owner:\n${JSON.stringify(rejectedLock, null, 2)}`);
|
|
2287
|
+
}
|
|
2288
|
+
const lockRowsAfterReject = hccJson(['lock', 'list', '--all']);
|
|
2289
|
+
if (lockRowsAfterReject.some((lock) => lock.resource === 'web/identity-spoof-lock')) {
|
|
2290
|
+
fail(`rejected web lock-acquire still inserted a lock:\n${JSON.stringify(lockRowsAfterReject, null, 2)}`);
|
|
2291
|
+
}
|
|
2292
|
+
hcc(['task', 'done', '--peer', managedActionSession.peer_id || managedActionSession.id, '--id', taskMatch[1], '--summary', 'web action cleanup'], {
|
|
2293
|
+
env: { ...env, HCC_PEER: managedActionSession.peer_id || managedActionSession.id }
|
|
2294
|
+
});
|
|
2295
|
+
hcc(['task', 'done', '--peer', 'web-lock-owner', '--id', ownerTaskMatch[1], '--summary', 'web lock owner cleanup'], {
|
|
2296
|
+
env: { ...env, HCC_PEER: 'web-lock-owner' }
|
|
2297
|
+
});
|
|
2298
|
+
await stopSession(managedActionSession.id);
|
|
2299
|
+
|
|
2300
|
+
hcc(['register', '--peer', 'detected-msg-peer', '--kind', 'codex', '--role', 'peer']);
|
|
2301
|
+
const spoofedDetectedMessage = await (await runtimeFetch('/api/detected/detected-msg-peer/msg', {
|
|
2302
|
+
method: 'POST',
|
|
2303
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2304
|
+
body: JSON.stringify({ from: 'web-action-peer', body: 'detected sender spoof attempt' })
|
|
2305
|
+
}, { root })).json();
|
|
2306
|
+
if (!spoofedDetectedMessage.ok) fail(`detected message send failed:\n${JSON.stringify(spoofedDetectedMessage, null, 2)}`);
|
|
2307
|
+
const detectedInbox = hccJson(['msg', 'inbox', '--peer', 'detected-msg-peer', '--all']);
|
|
2308
|
+
const detectedMsg = detectedInbox.find((message) => message.body === 'detected sender spoof attempt');
|
|
2309
|
+
if (!detectedMsg || detectedMsg.sender !== 'web') {
|
|
2310
|
+
fail(`detected message sender spoof was not forced to web:\n${JSON.stringify(detectedInbox, null, 2)}`);
|
|
2311
|
+
}
|
|
1965
2312
|
|
|
1966
2313
|
const claudeResumeName = `web-claude-resume-${testId}`;
|
|
1967
2314
|
const claudeResume = await startProvider({ kind: 'claude', mode: 'resume', resume: claudeResumeName });
|
|
@@ -2069,11 +2416,11 @@ async function multiProjectWebWorkflow() {
|
|
|
2069
2416
|
|
|
2070
2417
|
async function tmuxBackedStartWorkflow() {
|
|
2071
2418
|
if (!tmuxAvailable()) {
|
|
2072
|
-
log('[5/
|
|
2419
|
+
log('[5/13] tmux-backed start skipped (tmux not installed)');
|
|
2073
2420
|
return;
|
|
2074
2421
|
}
|
|
2075
2422
|
|
|
2076
|
-
log('[5/
|
|
2423
|
+
log('[5/13] tmux-backed start + websocket + restore');
|
|
2077
2424
|
const file = path.join(outDir, 'pty-ok');
|
|
2078
2425
|
const started = hcc(['peer', 'start', 'shell-a', '--kind', 'shell', '--', 'bash']);
|
|
2079
2426
|
const pane = parsePane(started);
|
|
@@ -2087,11 +2434,18 @@ async function tmuxBackedStartWorkflow() {
|
|
|
2087
2434
|
await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
|
|
2088
2435
|
await expectBoundedTmuxStream('tmux-backed FIFO stream');
|
|
2089
2436
|
|
|
2090
|
-
await
|
|
2437
|
+
const heldWs = await openTerminalWebSocket('shell-a');
|
|
2438
|
+
try {
|
|
2439
|
+
await stopRuntime();
|
|
2440
|
+
await waitFor(() => heldWs.readyState === WebSocket.CLOSED || heldWs.readyState === WebSocket.CLOSING, 'runtime websocket close', 2000);
|
|
2441
|
+
} finally {
|
|
2442
|
+
try { heldWs.close(); } catch {}
|
|
2443
|
+
}
|
|
2091
2444
|
run('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}']);
|
|
2092
2445
|
startRuntime();
|
|
2093
2446
|
await waitRuntime();
|
|
2094
|
-
await waitFor(() => hcc(['peer', 'list']).includes('shell-a'), 'tmux-backed peer restore');
|
|
2447
|
+
await waitFor(() => hcc(['peer', 'list']).includes('shell-a'), 'tmux-backed peer restore after websocket shutdown');
|
|
2448
|
+
|
|
2095
2449
|
const restoredFile = path.join(outDir, 'pty-restored-ok');
|
|
2096
2450
|
hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
|
|
2097
2451
|
await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
|
|
@@ -2178,11 +2532,11 @@ async function tmuxBackedStartWorkflow() {
|
|
|
2178
2532
|
|
|
2179
2533
|
async function shimTmuxWorkflow() {
|
|
2180
2534
|
if (!tmuxAvailable()) {
|
|
2181
|
-
log('[6/
|
|
2535
|
+
log('[6/13] shim tmux-backed launch skipped (tmux not installed)');
|
|
2182
2536
|
return;
|
|
2183
2537
|
}
|
|
2184
2538
|
|
|
2185
|
-
log('[6/
|
|
2539
|
+
log('[6/13] shim tmux-backed launch');
|
|
2186
2540
|
const shim = path.join(home, '.hcc-shims', 'claude');
|
|
2187
2541
|
const codexShim = path.join(home, '.hcc-shims', 'codex');
|
|
2188
2542
|
const claudeVersion = run(shim, ['--version'], { cwd: root, env });
|
|
@@ -2253,6 +2607,9 @@ async function shimTmuxWorkflow() {
|
|
|
2253
2607
|
const firstEnvFile = path.join(outDir, 'shim-env-first');
|
|
2254
2608
|
hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${firstEnvFile}`]);
|
|
2255
2609
|
await waitForFile(firstEnvFile, 'shim-first', 'shim first env');
|
|
2610
|
+
const shimInternalEnvFile = path.join(outDir, 'shim-internal-env');
|
|
2611
|
+
hcc(['inject', peer, `printf '%s|%s\\n' "\${HCC_SHIM_ENSURED-unset}" "\${HCC_SKIP_SHIM_INSTALL-unset}" > ${shimInternalEnvFile}`]);
|
|
2612
|
+
await waitForFile(shimInternalEnvFile, 'unset|unset', 'shim internal env cleanup');
|
|
2256
2613
|
|
|
2257
2614
|
const restarted = run(shim, ['--resume', 'shim-regression-session'], {
|
|
2258
2615
|
cwd: root,
|
|
@@ -2274,12 +2631,34 @@ async function shimTmuxWorkflow() {
|
|
|
2274
2631
|
HCC_FAKE_STAY_ALIVE: '0',
|
|
2275
2632
|
HCC_FAKE_LOG: exitedLog
|
|
2276
2633
|
};
|
|
2277
|
-
const
|
|
2634
|
+
const exitedFirstResult = runMaybe(shim, ['--resume', exitedResume], { cwd: root, env: exitedEnv });
|
|
2635
|
+
if (exitedFirstResult.status !== 0) {
|
|
2636
|
+
const allShimLines = fs.readFileSync(shim, 'utf8').split('\n');
|
|
2637
|
+
const shimLines = allShimLines
|
|
2638
|
+
.slice(0, 35)
|
|
2639
|
+
.concat(['...'])
|
|
2640
|
+
.concat(allShimLines.slice(139))
|
|
2641
|
+
.map((line, index) => {
|
|
2642
|
+
if (index < 35) return `${index + 1}: ${line}`;
|
|
2643
|
+
if (index === 35) return line;
|
|
2644
|
+
return `${index + 104}: ${line}`;
|
|
2645
|
+
})
|
|
2646
|
+
.join('\n');
|
|
2647
|
+
fail(`${sh([shim, '--resume', exitedResume].join(' '))} failed\n${exitedFirstResult.stdout || ''}${exitedFirstResult.stderr || ''}\nshim excerpt:\n${shimLines}`);
|
|
2648
|
+
}
|
|
2649
|
+
const exitedFirst = exitedFirstResult.stdout || '';
|
|
2278
2650
|
const exitedPeerMatch = exitedFirst.match(/started\s+(\S+)\s+\(/);
|
|
2279
2651
|
if (!exitedPeerMatch) fail(`exited shim did not start a peer:\n${exitedFirst}`);
|
|
2280
2652
|
const exitedPeer = exitedPeerMatch[1];
|
|
2281
|
-
parsePane(exitedFirst);
|
|
2282
|
-
|
|
2653
|
+
const exitedPane = parsePane(exitedFirst);
|
|
2654
|
+
try {
|
|
2655
|
+
await waitForFileLineCount(exitedLog, 1, 'shim exited first provider launch');
|
|
2656
|
+
} catch (err) {
|
|
2657
|
+
const paneCapture = runMaybe('tmux', ['capture-pane', '-p', '-S', '-80', '-t', exitedPane]);
|
|
2658
|
+
const peerList = hccMaybe(['peer', 'list']);
|
|
2659
|
+
const shimHead = fs.readFileSync(shim, 'utf8').split('\n').slice(0, 18).join('\n');
|
|
2660
|
+
fail(`${err.message}\nshim output:\n${exitedFirst}\nshim stderr:\n${exitedFirstResult.stderr || ''}\npeer list:\n${peerList.stdout || ''}${peerList.stderr || ''}\npane ${exitedPane}:\n${paneCapture.stdout || ''}${paneCapture.stderr || ''}\nshim head:\n${shimHead}`);
|
|
2661
|
+
}
|
|
2283
2662
|
const fallbackFile = path.join(outDir, 'shim-exited-fallback');
|
|
2284
2663
|
hcc(['inject', exitedPeer, `printf '%s\\n' fallback > ${sh(fallbackFile)}`]);
|
|
2285
2664
|
await waitForFile(fallbackFile, 'fallback', 'shim exited fallback shell');
|
|
@@ -2292,11 +2671,11 @@ async function shimTmuxWorkflow() {
|
|
|
2292
2671
|
async function tmuxWorkflow() {
|
|
2293
2672
|
const tmuxVersion = runMaybe('tmux', ['-V']);
|
|
2294
2673
|
if (tmuxVersion.status !== 0) {
|
|
2295
|
-
log('[7/
|
|
2674
|
+
log('[7/13] tmux skipped (tmux not installed)');
|
|
2296
2675
|
return;
|
|
2297
2676
|
}
|
|
2298
2677
|
|
|
2299
|
-
log('[7/
|
|
2678
|
+
log('[7/13] tmux attach + websocket + force');
|
|
2300
2679
|
run('tmux', ['new-session', '-d', '-s', tmuxSession, '-c', os.tmpdir(), 'bash --noprofile --norc']);
|
|
2301
2680
|
tmuxStarted = true;
|
|
2302
2681
|
const pane = run('tmux', ['display-message', '-p', '-t', `${tmuxSession}:0.0`, '#{pane_id}']).trim();
|
|
@@ -2318,23 +2697,33 @@ async function tmuxWorkflow() {
|
|
|
2318
2697
|
|
|
2319
2698
|
async function askBroadcastWorkflow() {
|
|
2320
2699
|
if (!tmuxAvailable()) {
|
|
2321
|
-
log('[8/
|
|
2700
|
+
log('[8/13] ask/broadcast injection skipped (tmux not installed)');
|
|
2322
2701
|
return;
|
|
2323
2702
|
}
|
|
2324
2703
|
|
|
2325
|
-
log('[8/
|
|
2704
|
+
log('[8/13] ask/broadcast injection');
|
|
2326
2705
|
const askFile = path.join(outDir, 'ask-ok');
|
|
2327
2706
|
parsePane(hcc(['peer', 'start', 'shell-b', '--kind', 'shell', '--', 'bash']));
|
|
2328
2707
|
hcc(['ask', 'shell-b', `echo ASK_OK > ${askFile}`, '--from', 'human', '--inject']);
|
|
2329
2708
|
await waitForFile(askFile, 'ASK_OK', 'ask injection');
|
|
2330
2709
|
if (!hcc(['msg', 'inbox', '--peer', 'shell-b', '--all']).includes('ASK_OK')) fail('ask durable message missing');
|
|
2710
|
+
const shellDefaultDispatch = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'shell-b', '--title', 'shell default dispatch']);
|
|
2711
|
+
if (shellDefaultDispatch.injected !== false || shellDefaultDispatch.injection_reason !== 'unsupported_session_kind') {
|
|
2712
|
+
fail(`task dispatch injected default natural-language prompt into shell session:\n${JSON.stringify(shellDefaultDispatch, null, 2)}`);
|
|
2713
|
+
}
|
|
2714
|
+
const dispatchFile = path.join(outDir, 'dispatch-ok');
|
|
2715
|
+
const shellCommandDispatch = hccJson(['task', 'dispatch', '--from', 'human', '--to', 'shell-b', '--title', 'shell command dispatch', '--message', `echo DISPATCH_OK > ${dispatchFile}`, '--force']);
|
|
2716
|
+
if (!shellCommandDispatch.injected || shellCommandDispatch.delivery !== 'message+inject') {
|
|
2717
|
+
fail(`task dispatch did not inject explicit shell-safe message:\n${JSON.stringify(shellCommandDispatch, null, 2)}`);
|
|
2718
|
+
}
|
|
2719
|
+
await waitForFile(dispatchFile, 'DISPATCH_OK', 'task dispatch injection');
|
|
2331
2720
|
const broadcastFile = path.join(outDir, 'broadcast-ok');
|
|
2332
2721
|
hcc(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject']);
|
|
2333
2722
|
await waitForFile(broadcastFile, 'BROADCAST_OK', 'broadcast injection');
|
|
2334
2723
|
}
|
|
2335
2724
|
|
|
2336
2725
|
async function downGcPackWorkflow() {
|
|
2337
|
-
log('[9/
|
|
2726
|
+
log('[9/13] down/gc/pack');
|
|
2338
2727
|
hccMaybe(['peer', 'stop', 'shell-a']);
|
|
2339
2728
|
hccMaybe(['peer', 'stop', 'shell-b']);
|
|
2340
2729
|
await stopRuntime();
|
|
@@ -2378,8 +2767,8 @@ async function downGcPackWorkflow() {
|
|
|
2378
2767
|
"tags:\n - 'v*'",
|
|
2379
2768
|
'workflow_dispatch:',
|
|
2380
2769
|
'contents: write',
|
|
2381
|
-
'actions/checkout@
|
|
2382
|
-
'actions/setup-node@
|
|
2770
|
+
'actions/checkout@v5',
|
|
2771
|
+
'actions/setup-node@v5',
|
|
2383
2772
|
'node-version: 24',
|
|
2384
2773
|
'npm run release:check',
|
|
2385
2774
|
'npm run release:github:dry-run',
|
|
@@ -2391,7 +2780,7 @@ async function downGcPackWorkflow() {
|
|
|
2391
2780
|
}
|
|
2392
2781
|
|
|
2393
2782
|
function oldNameScan() {
|
|
2394
|
-
log('[10/
|
|
2783
|
+
log('[10/13] old-name scan');
|
|
2395
2784
|
const oldNamePattern = [
|
|
2396
2785
|
'agent' + 'mesh',
|
|
2397
2786
|
'Agent' + 'mesh',
|
|
@@ -2402,20 +2791,144 @@ function oldNameScan() {
|
|
|
2402
2791
|
'ACTIVE_' + 'AGENT_' + 'TTL',
|
|
2403
2792
|
'--' + 'agent\\b'
|
|
2404
2793
|
].join('|');
|
|
2405
|
-
const
|
|
2406
|
-
|
|
2407
|
-
'
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2794
|
+
const oldNameRegex = new RegExp(oldNamePattern);
|
|
2795
|
+
const files = run('git', ['ls-files'])
|
|
2796
|
+
.split('\n')
|
|
2797
|
+
.map((file) => file.trim())
|
|
2798
|
+
.filter(Boolean)
|
|
2799
|
+
.filter((file) => !file.includes('/node_modules/') && !file.endsWith('.tgz'));
|
|
2800
|
+
const matches = [];
|
|
2801
|
+
for (const file of files) {
|
|
2802
|
+
const fullPath = path.join(repoRoot, file);
|
|
2803
|
+
let content = '';
|
|
2804
|
+
try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
2805
|
+
const lines = content.split('\n');
|
|
2806
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
2807
|
+
if (oldNameRegex.test(lines[i])) matches.push(`${file}:${i + 1}: ${lines[i]}`);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
if (matches.length) fail(`old names found:\n${matches.join('\n')}`);
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
function identityEnforcementWorkflow() {
|
|
2814
|
+
log('[11/13] identity enforcement');
|
|
2815
|
+
const identityRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-identity-root-${testId}-`));
|
|
2816
|
+
const identityHome = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-identity-home-${testId}-`));
|
|
2817
|
+
const identityEnv = {
|
|
2818
|
+
...env,
|
|
2819
|
+
HOME: identityHome,
|
|
2820
|
+
HCC_PEER: 'real-peer'
|
|
2821
|
+
};
|
|
2822
|
+
delete identityEnv.HCC_ROOT;
|
|
2823
|
+
delete identityEnv.HCC_DB;
|
|
2824
|
+
try {
|
|
2825
|
+
hccFrom(['register', '--peer', 'fake-peer', '--kind', 'codex', '--role', 'peer'], identityRoot, { env: identityEnv });
|
|
2826
|
+
const envOut = hccFrom(['env', '--peer', 'fake-peer'], identityRoot, { env: identityEnv });
|
|
2827
|
+
if (!envOut.includes('HCC_PEER=fake-peer')) {
|
|
2828
|
+
fail(`env did not export explicit target peer:\n${envOut}`);
|
|
2829
|
+
}
|
|
2830
|
+
const created = hccFrom(['task', 'create', '--from', 'fake-peer', '--title', 'identity guard'], identityRoot, { env: identityEnv });
|
|
2831
|
+
const match = created.match(/created task #(\d+):/);
|
|
2832
|
+
if (!match) fail(`cannot parse identity task id:\n${created}`);
|
|
2833
|
+
const taskId = match[1];
|
|
2834
|
+
hccFrom(['task', 'claim', '--peer', 'fake-peer', '--id', taskId], identityRoot, { env: identityEnv });
|
|
2835
|
+
|
|
2836
|
+
const dbPath = path.join(identityRoot, '.hello-cc', 'mesh.db');
|
|
2837
|
+
const db = new DatabaseSync(dbPath);
|
|
2838
|
+
try {
|
|
2839
|
+
const task = db.prepare('SELECT owner, created_by FROM tasks WHERE id = ?').get(Number(taskId));
|
|
2840
|
+
const fakePeer = db.prepare('SELECT id FROM peers WHERE id = ?').get('fake-peer');
|
|
2841
|
+
if (task?.owner !== 'real-peer' || task?.created_by !== 'real-peer' || fakePeer) {
|
|
2842
|
+
fail(`system peer identity was not enforced:\n${JSON.stringify({ task, fakePeer }, null, 2)}`);
|
|
2843
|
+
}
|
|
2844
|
+
db.prepare(`
|
|
2845
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
|
|
2846
|
+
VALUES ('inspect-peer', 'codex', 'peer', ?, '', NULL, 'working', '', 1, 1)
|
|
2847
|
+
`).run(identityRoot);
|
|
2848
|
+
db.prepare(`
|
|
2849
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
|
|
2850
|
+
VALUES ('owner-peer', 'codex', 'peer', ?, '', NULL, 'working', '', 1, 1)
|
|
2851
|
+
`).run(identityRoot);
|
|
2852
|
+
db.prepare(`
|
|
2853
|
+
INSERT INTO tasks(title, body, status, assignee, owner, priority, created_by, created_at, updated_at)
|
|
2854
|
+
VALUES ('owned identity guard', '', 'claimed', NULL, 'owner-peer', 100, 'owner-peer', 1, 1)
|
|
2855
|
+
`).run();
|
|
2856
|
+
} finally {
|
|
2857
|
+
db.close();
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
const statusOut = hccFrom(['status', '--peer', 'inspect-peer'], identityRoot, { env: identityEnv });
|
|
2861
|
+
if (!statusOut.includes('inbox(inspect-peer)')) {
|
|
2862
|
+
fail(`status did not inspect explicit target peer:\n${statusOut}`);
|
|
2863
|
+
}
|
|
2864
|
+
const stateOut = hccFrom(['state', '--peer', 'inspect-peer'], identityRoot, { env: identityEnv });
|
|
2865
|
+
if (!stateOut.includes('peer: inspect-peer')) {
|
|
2866
|
+
fail(`state did not inspect explicit target peer:\n${stateOut}`);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
const owned = hccFromMaybe(['task', 'done', '--peer', 'owner-peer', '--id', '2', '--summary', 'should not pass'], identityRoot, { env: identityEnv });
|
|
2870
|
+
if (owned.status === 0 || !`${owned.stdout}\n${owned.stderr}`.includes('Task #2 is owned by owner-peer')) {
|
|
2871
|
+
fail(`owned task mutation was not rejected:\nstdout=${owned.stdout}\nstderr=${owned.stderr}`);
|
|
2872
|
+
}
|
|
2873
|
+
const forcedOwned = hccFromMaybe(['task', 'done', '--force', '--peer', 'owner-peer', '--id', '2', '--summary', 'should not pass'], identityRoot, { env: identityEnv });
|
|
2874
|
+
if (forcedOwned.status === 0 || !`${forcedOwned.stdout}\n${forcedOwned.stderr}`.includes('Task #2 is owned by owner-peer')) {
|
|
2875
|
+
fail(`forced owned task mutation was not rejected:\nstdout=${forcedOwned.stdout}\nstderr=${forcedOwned.stderr}`);
|
|
2876
|
+
}
|
|
2877
|
+
const db2 = new DatabaseSync(dbPath);
|
|
2878
|
+
try {
|
|
2879
|
+
const task = db2.prepare('SELECT owner, status FROM tasks WHERE id = 2').get();
|
|
2880
|
+
const notice = db2.prepare(`
|
|
2881
|
+
SELECT sender, recipient, kind
|
|
2882
|
+
FROM messages
|
|
2883
|
+
WHERE task_id = 2 AND kind = 'task.owner-conflict'
|
|
2884
|
+
`).get();
|
|
2885
|
+
if (task?.owner !== 'owner-peer' || task?.status !== 'claimed' ||
|
|
2886
|
+
!notice || notice.sender !== 'real-peer' || notice.recipient !== 'owner-peer') {
|
|
2887
|
+
fail(`owned task mutation did not preserve owner and notify:\n${JSON.stringify({ task, notice }, null, 2)}`);
|
|
2888
|
+
}
|
|
2889
|
+
} finally {
|
|
2890
|
+
db2.close();
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
hccFrom(['run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: identityEnv });
|
|
2894
|
+
const db3 = new DatabaseSync(dbPath);
|
|
2895
|
+
try {
|
|
2896
|
+
const realRunner = db3.prepare('SELECT id, status FROM peers WHERE id = ?').get('real-peer');
|
|
2897
|
+
const fakeRunner = db3.prepare('SELECT id FROM peers WHERE id = ?').get('fake-runner');
|
|
2898
|
+
if (!realRunner || realRunner.status !== 'exited' || fakeRunner) {
|
|
2899
|
+
fail(`run did not enforce system peer identity:\n${JSON.stringify({ realRunner, fakeRunner }, null, 2)}`);
|
|
2900
|
+
}
|
|
2901
|
+
} finally {
|
|
2902
|
+
db3.close();
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
hccFrom(['peer', 'stop', 'fake-target'], identityRoot, { env: identityEnv });
|
|
2906
|
+
const stopRequested = eventPayloads('peer.stop.requested', 5, dbPath)
|
|
2907
|
+
.find((event) => event.payload?.target_peer === 'fake-target');
|
|
2908
|
+
if (!stopRequested ||
|
|
2909
|
+
stopRequested.actor !== 'real-peer' ||
|
|
2910
|
+
stopRequested.payload.actor_peer !== 'real-peer' ||
|
|
2911
|
+
stopRequested.payload.source !== 'cli' ||
|
|
2912
|
+
stopRequested.payload.admin !== true) {
|
|
2913
|
+
fail(`peer stop request did not record real actor and target:\n${JSON.stringify(stopRequested, null, 2)}`);
|
|
2914
|
+
}
|
|
2915
|
+
const stoppedFallback = eventPayloads('peer.stopped', 5, dbPath)
|
|
2916
|
+
.find((event) => event.payload?.target_peer === 'fake-target');
|
|
2917
|
+
if (!stoppedFallback ||
|
|
2918
|
+
stoppedFallback.actor !== 'real-peer' ||
|
|
2919
|
+
stoppedFallback.payload.actor_peer !== 'real-peer' ||
|
|
2920
|
+
stoppedFallback.payload.source !== 'cli' ||
|
|
2921
|
+
stoppedFallback.payload.admin !== true) {
|
|
2922
|
+
fail(`peer stop fallback did not record real actor and target:\n${JSON.stringify(stoppedFallback, null, 2)}`);
|
|
2923
|
+
}
|
|
2924
|
+
} finally {
|
|
2925
|
+
try { fs.rmSync(identityRoot, { recursive: true, force: true }); } catch {}
|
|
2926
|
+
try { fs.rmSync(identityHome, { recursive: true, force: true }); } catch {}
|
|
2927
|
+
}
|
|
2415
2928
|
}
|
|
2416
2929
|
|
|
2417
2930
|
async function syntaxAndHelp() {
|
|
2418
|
-
log('[
|
|
2931
|
+
log('[12/13] syntax/help');
|
|
2419
2932
|
run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
|
|
2420
2933
|
for (const file of libModuleFiles()) {
|
|
2421
2934
|
run(process.execPath, ['--check', path.join(repoRoot, file)]);
|
|
@@ -2601,7 +3114,19 @@ async function syntaxAndHelp() {
|
|
|
2601
3114
|
'} = createWebPeerActions({',
|
|
2602
3115
|
'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
|
|
2603
3116
|
"const readOnly = ['status', 'state', 'inbox'].includes(action)",
|
|
2604
|
-
|
|
3117
|
+
'resolveWebActionSession(reqCtx, peer, input, req)',
|
|
3118
|
+
'action_token: session.actionToken || null',
|
|
3119
|
+
"const sender = 'web';",
|
|
3120
|
+
'function auditPayload({ actor = null, target = null, source = ',
|
|
3121
|
+
'payload.actor_peer = actor',
|
|
3122
|
+
'payload.target_peer = target',
|
|
3123
|
+
"auditActorPeer: 'web'",
|
|
3124
|
+
"auditSource: 'web'",
|
|
3125
|
+
"addEvent(db, 'peer.start.requested'",
|
|
3126
|
+
"addEvent(db, 'peer.attach.requested'",
|
|
3127
|
+
"addEvent(db, 'peer.stop.requested'",
|
|
3128
|
+
"addEvent(eventDb, 'web.session.stop_requested'",
|
|
3129
|
+
"addEvent(db, 'tmux.session.gc'"
|
|
2605
3130
|
]) {
|
|
2606
3131
|
if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
|
|
2607
3132
|
}
|
|
@@ -2618,9 +3143,17 @@ async function syntaxAndHelp() {
|
|
|
2618
3143
|
if (hccSource.includes(helper)) fail(`CLI still embeds web peer action helper: ${helper}`);
|
|
2619
3144
|
}
|
|
2620
3145
|
for (const expected of [
|
|
3146
|
+
'function webMutationPeer(peer, input = {})',
|
|
2621
3147
|
'function webPeerAction(projectCtx, peer, action, input = {})',
|
|
3148
|
+
'function webAuditPayload(peer, input = {}, extra = {})',
|
|
3149
|
+
"source: 'web'",
|
|
3150
|
+
'actor_peer: actorPeer',
|
|
3151
|
+
'target_peer: peer',
|
|
3152
|
+
"throw new CliError('PEER_IDENTITY_MISMATCH'",
|
|
3153
|
+
"throw new CliError('PEER_IDENTITY_REQUIRED'",
|
|
2622
3154
|
'claimNextTasksForPeer(db, peer, { force: Boolean(input.force), count })',
|
|
2623
3155
|
'takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter, source: ',
|
|
3156
|
+
"assertTaskOwnerForMutation(db, peer, task, 'lock-acquire')",
|
|
2624
3157
|
'const status = statusSummary(projectCtx, peer)',
|
|
2625
3158
|
"normalized === 'task-next'",
|
|
2626
3159
|
"normalized === 'lock-acquire'",
|
|
@@ -2777,6 +3310,7 @@ async function syntaxAndHelp() {
|
|
|
2777
3310
|
'function installShims',
|
|
2778
3311
|
'function uninstallShims',
|
|
2779
3312
|
'function verifyShims',
|
|
3313
|
+
'function shimStatus',
|
|
2780
3314
|
'function findRealBinary',
|
|
2781
3315
|
'function fsExists'
|
|
2782
3316
|
]) {
|
|
@@ -2804,7 +3338,8 @@ async function syntaxAndHelp() {
|
|
|
2804
3338
|
}
|
|
2805
3339
|
if (!integrationShimScriptSource.includes('function generateShim') ||
|
|
2806
3340
|
!integrationShimScriptSource.includes('hello-cc shim for ${tool.name}') ||
|
|
2807
|
-
!integrationShimScriptSource.includes('HCC_SHIM_ENSURED')
|
|
3341
|
+
!integrationShimScriptSource.includes('HCC_SHIM_ENSURED') ||
|
|
3342
|
+
!integrationShimScriptSource.includes('HCC_SKIP_SHIM_INSTALL')) {
|
|
2808
3343
|
fail('integrations shim script module is missing expected generateShim wiring');
|
|
2809
3344
|
}
|
|
2810
3345
|
if (!shellPathSource.includes('function installPathEntry') ||
|
|
@@ -2829,7 +3364,7 @@ async function syntaxAndHelp() {
|
|
|
2829
3364
|
if (typeof integrationHooks[name] !== 'function') fail(`integrations hook module missing export: ${name}`);
|
|
2830
3365
|
if (setupModule[name] !== integrationHooks[name]) fail(`setup hook export mismatch: ${name}`);
|
|
2831
3366
|
}
|
|
2832
|
-
for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'findRealBinary']) {
|
|
3367
|
+
for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'shimStatus', 'findRealBinary']) {
|
|
2833
3368
|
if (typeof integrationShims[name] !== 'function') fail(`integrations shim module missing export: ${name}`);
|
|
2834
3369
|
if (setupModule[name] !== integrationShims[name]) fail(`setup shim export mismatch: ${name}`);
|
|
2835
3370
|
}
|
|
@@ -2843,6 +3378,147 @@ async function syntaxAndHelp() {
|
|
|
2843
3378
|
if (typeof shellPath[name] !== 'function') fail(`shell path module missing export: ${name}`);
|
|
2844
3379
|
if (setupModule[name] !== shellPath[name]) fail(`setup shell path export mismatch: ${name}`);
|
|
2845
3380
|
}
|
|
3381
|
+
const bashRc = '# regression rc\n[ -z "$PS1" ] && return\nexport PATH="/late:$PATH"\n';
|
|
3382
|
+
const bashPath = shellPath.insertPathEntry(bashRc, 'bash');
|
|
3383
|
+
if ((bashPath.match(/# hello-cc shims/g) || []).length !== 2 ||
|
|
3384
|
+
bashPath.indexOf('# hello-cc shims (early)') > bashPath.indexOf('[ -z "$PS1" ] && return') ||
|
|
3385
|
+
bashPath.lastIndexOf('# hello-cc shims (final)') < bashPath.indexOf('export PATH="/late:$PATH"')) {
|
|
3386
|
+
fail(`bash shell PATH insertion lost early/final placement:\n${bashPath}`);
|
|
3387
|
+
}
|
|
3388
|
+
const zshPath = shellPath.insertPathEntry('export PATH="$HOME/bin:$PATH"\n', 'zsh');
|
|
3389
|
+
if ((zshPath.match(/# hello-cc shims/g) || []).length !== 1 ||
|
|
3390
|
+
zshPath.includes('# hello-cc shims (early)') ||
|
|
3391
|
+
!zshPath.includes('# hello-cc shims (final)')) {
|
|
3392
|
+
fail(`zsh shell PATH insertion should install one final entry:\n${zshPath}`);
|
|
3393
|
+
}
|
|
3394
|
+
const fishPath = shellPath.insertPathEntry('set -gx PATH $HOME/bin $PATH\n', 'fish');
|
|
3395
|
+
if ((fishPath.match(/# hello-cc shims/g) || []).length !== 1 ||
|
|
3396
|
+
fishPath.includes('# hello-cc shims (early)') ||
|
|
3397
|
+
!fishPath.includes('# hello-cc shims (final)')) {
|
|
3398
|
+
fail(`fish shell PATH insertion should install one final entry:\n${fishPath}`);
|
|
3399
|
+
}
|
|
3400
|
+
const customRc = [
|
|
3401
|
+
'export PATH="$HOME/.hcc-shims:$PATH" # user custom shim dir',
|
|
3402
|
+
shellPath.pathEntryLine('bash', 'final')
|
|
3403
|
+
].join('\n');
|
|
3404
|
+
const cleanedRc = shellPath.removePathEntryLines(customRc);
|
|
3405
|
+
if (!cleanedRc.includes('user custom shim dir') || cleanedRc.includes('# hello-cc shims')) {
|
|
3406
|
+
fail(`shell PATH cleanup removed a non-hello-cc custom line or kept hello-cc line:\n${cleanedRc}`);
|
|
3407
|
+
}
|
|
3408
|
+
const noProviderHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-no-provider-home-'));
|
|
3409
|
+
try {
|
|
3410
|
+
const noProviderEnv = {
|
|
3411
|
+
...env,
|
|
3412
|
+
HOME: noProviderHome,
|
|
3413
|
+
PATH: '/usr/bin:/bin',
|
|
3414
|
+
SHELL: '/bin/bash'
|
|
3415
|
+
};
|
|
3416
|
+
const noProviderInstall = run(process.execPath, [hccBin, '--root', root, 'shim', 'install'], { env: noProviderEnv });
|
|
3417
|
+
if (!noProviderInstall.includes('no shims installed')) {
|
|
3418
|
+
fail(`shim install without providers did not report no shims:\n${noProviderInstall}`);
|
|
3419
|
+
}
|
|
3420
|
+
const noProviderBashrc = path.join(noProviderHome, '.bashrc');
|
|
3421
|
+
if (fs.existsSync(noProviderBashrc) && fs.readFileSync(noProviderBashrc, 'utf8').includes('.hcc-shims')) {
|
|
3422
|
+
fail(`shim install without providers modified shell PATH:\n${fs.readFileSync(noProviderBashrc, 'utf8')}`);
|
|
3423
|
+
}
|
|
3424
|
+
} finally {
|
|
3425
|
+
try { fs.rmSync(noProviderHome, { recursive: true, force: true }); } catch {}
|
|
3426
|
+
}
|
|
3427
|
+
const fishHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-fish-home-'));
|
|
3428
|
+
try {
|
|
3429
|
+
const fishEnv = {
|
|
3430
|
+
...env,
|
|
3431
|
+
HOME: fishHome,
|
|
3432
|
+
SHELL: '/usr/bin/fish'
|
|
3433
|
+
};
|
|
3434
|
+
const fishInstall = run(process.execPath, [hccBin, '--root', root, 'shim', 'install'], { env: fishEnv });
|
|
3435
|
+
const fishRc = path.join(fishHome, '.config', 'fish', 'config.fish');
|
|
3436
|
+
if (!fishInstall.includes('PATH updated') ||
|
|
3437
|
+
!fs.existsSync(fishRc) ||
|
|
3438
|
+
!fs.readFileSync(fishRc, 'utf8').includes('# hello-cc shims (final)')) {
|
|
3439
|
+
fail(`fish shim install did not create config.fish PATH entry:\n${fishInstall}\n${fs.existsSync(fishRc) ? fs.readFileSync(fishRc, 'utf8') : '(missing)'}`);
|
|
3440
|
+
}
|
|
3441
|
+
} finally {
|
|
3442
|
+
try { fs.rmSync(fishHome, { recursive: true, force: true }); } catch {}
|
|
3443
|
+
}
|
|
3444
|
+
const missingRcHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-missing-rc-home-'));
|
|
3445
|
+
try {
|
|
3446
|
+
const missingRcEnv = {
|
|
3447
|
+
...env,
|
|
3448
|
+
HOME: missingRcHome,
|
|
3449
|
+
SHELL: '/bin/bash'
|
|
3450
|
+
};
|
|
3451
|
+
const uninstallOutput = run(process.execPath, [hccBin, '--root', root, 'shim', 'uninstall'], { env: missingRcEnv });
|
|
3452
|
+
if (uninstallOutput.includes('PATH entry not removed') || !uninstallOutput.includes('PATH entry not present')) {
|
|
3453
|
+
fail(`shim uninstall reported missing shell rc as a failure:\n${uninstallOutput}`);
|
|
3454
|
+
}
|
|
3455
|
+
} finally {
|
|
3456
|
+
try { fs.rmSync(missingRcHome, { recursive: true, force: true }); } catch {}
|
|
3457
|
+
}
|
|
3458
|
+
const partialStatusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-status-home-'));
|
|
3459
|
+
try {
|
|
3460
|
+
const partialStatusDir = path.join(partialStatusHome, '.hcc-shims');
|
|
3461
|
+
fs.mkdirSync(partialStatusDir, { recursive: true });
|
|
3462
|
+
fs.writeFileSync(path.join(partialStatusDir, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
|
3463
|
+
const partialStatusEnv = {
|
|
3464
|
+
...env,
|
|
3465
|
+
HOME: partialStatusHome,
|
|
3466
|
+
SHELL: '/bin/bash'
|
|
3467
|
+
};
|
|
3468
|
+
const statusOutput = run(process.execPath, [hccBin, '--root', root, 'shim', 'status'], { env: partialStatusEnv });
|
|
3469
|
+
if (!statusOutput.includes('claude: installed') ||
|
|
3470
|
+
!statusOutput.includes('codex: missing') ||
|
|
3471
|
+
!statusOutput.includes('status: partial')) {
|
|
3472
|
+
fail(`shim status did not report partial install per tool:\n${statusOutput}`);
|
|
3473
|
+
}
|
|
3474
|
+
} finally {
|
|
3475
|
+
try { fs.rmSync(partialStatusHome, { recursive: true, force: true }); } catch {}
|
|
3476
|
+
}
|
|
3477
|
+
const partialShimHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-shim-home-'));
|
|
3478
|
+
const partialShimRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-partial-shim-root-'));
|
|
3479
|
+
try {
|
|
3480
|
+
const partialShimDir = path.join(partialShimHome, '.hcc-shims');
|
|
3481
|
+
fs.mkdirSync(partialShimDir, { recursive: true });
|
|
3482
|
+
fs.writeFileSync(path.join(partialShimDir, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
|
3483
|
+
const partialShimEnv = {
|
|
3484
|
+
...env,
|
|
3485
|
+
HOME: partialShimHome,
|
|
3486
|
+
SHELL: '/bin/bash'
|
|
3487
|
+
};
|
|
3488
|
+
run(process.execPath, [hccBin, '--root', partialShimRoot, 'setup', '--quiet'], { env: partialShimEnv });
|
|
3489
|
+
for (const name of ['claude', 'codex']) {
|
|
3490
|
+
if (!fs.existsSync(path.join(partialShimDir, name))) {
|
|
3491
|
+
fail(`setup did not repair partial shim install; missing ${name}`);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
} finally {
|
|
3495
|
+
try { fs.rmSync(partialShimHome, { recursive: true, force: true }); } catch {}
|
|
3496
|
+
try { fs.rmSync(partialShimRoot, { recursive: true, force: true }); } catch {}
|
|
3497
|
+
}
|
|
3498
|
+
const scanHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-scan-home-'));
|
|
3499
|
+
const scanRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-scan-root-'));
|
|
3500
|
+
try {
|
|
3501
|
+
const scanRealRoot = fs.realpathSync(scanRoot);
|
|
3502
|
+
const claudeSessionsDir = path.join(scanHome, '.claude', 'sessions');
|
|
3503
|
+
fs.mkdirSync(claudeSessionsDir, { recursive: true });
|
|
3504
|
+
fs.writeFileSync(path.join(claudeSessionsDir, 'scan-realpath.json'), JSON.stringify({
|
|
3505
|
+
pid: process.pid,
|
|
3506
|
+
sessionId: 'scan-realpath-session',
|
|
3507
|
+
cwd: scanRoot,
|
|
3508
|
+
status: 'running'
|
|
3509
|
+
}));
|
|
3510
|
+
const scanEnv = {
|
|
3511
|
+
...env,
|
|
3512
|
+
HOME: scanHome
|
|
3513
|
+
};
|
|
3514
|
+
const scanOutput = run(process.execPath, [hccBin, '--root', scanRealRoot, 'scan'], { env: scanEnv });
|
|
3515
|
+
if (!scanOutput.includes('claude') || !scanOutput.includes('scan-realpath')) {
|
|
3516
|
+
fail(`scan did not match discovered hccRoot through realpath comparison:\n${scanOutput}`);
|
|
3517
|
+
}
|
|
3518
|
+
} finally {
|
|
3519
|
+
try { fs.rmSync(scanHome, { recursive: true, force: true }); } catch {}
|
|
3520
|
+
try { fs.rmSync(scanRoot, { recursive: true, force: true }); } catch {}
|
|
3521
|
+
}
|
|
2846
3522
|
const cliRuntime = await import(path.join(repoRoot, 'lib', 'cli-runtime.mjs'));
|
|
2847
3523
|
for (const name of ['commandPath', 'createContext', 'packageRoot', 'shellCommand', 'tailFile']) {
|
|
2848
3524
|
if (typeof cliRuntime[name] !== 'function') fail(`CLI runtime module missing function: ${name}`);
|
|
@@ -3232,11 +3908,13 @@ async function syntaxAndHelp() {
|
|
|
3232
3908
|
],
|
|
3233
3909
|
events: [
|
|
3234
3910
|
{ id: 7, type: 'message.sent', actor: 'peer-a', task_id: 2, payload: '{}', created_at: 7 },
|
|
3235
|
-
{ id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 }
|
|
3911
|
+
{ id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 },
|
|
3912
|
+
{ id: 9, type: 'web.session.stop_requested', actor: 'web', task_id: null, payload: JSON.stringify({ actor_peer: 'web', target_peer: 'peer-b', peer: 'peer-b' }), created_at: 9 },
|
|
3913
|
+
{ id: 10, type: 'task.dispatched', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ actor_peer: 'peer-a', target_peer: 'peer-b', peer: 'peer-b', title: 'dispatch timeline' }), created_at: 10 }
|
|
3236
3914
|
]
|
|
3237
3915
|
}, 'peer-b');
|
|
3238
3916
|
const timelineIds = timelineItems.map((item) => item.id);
|
|
3239
|
-
if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8'])) {
|
|
3917
|
+
if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8', 'event:9', 'event:10'])) {
|
|
3240
3918
|
fail(`timelineFromRows filtering/order changed: ${JSON.stringify(timelineItems)}`);
|
|
3241
3919
|
}
|
|
3242
3920
|
const inbound = timelineItems.find((item) => item.id === 'message:2');
|
|
@@ -4545,6 +5223,10 @@ async function syntaxAndHelp() {
|
|
|
4545
5223
|
if (integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, {}, 'peer', 'shell').id !== 'env-peer') {
|
|
4546
5224
|
fail('peer identity resolveCurrentPeer ignored HCC_PEER');
|
|
4547
5225
|
}
|
|
5226
|
+
const correctedPeer = integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, { peer: 'manual-peer' }, 'peer', 'shell');
|
|
5227
|
+
if (correctedPeer.id !== 'env-peer' || correctedPeer.corrected_from !== 'manual-peer') {
|
|
5228
|
+
fail(`peer identity did not correct explicit peer to HCC_PEER: ${JSON.stringify(correctedPeer)}`);
|
|
5229
|
+
}
|
|
4548
5230
|
delete process.env.HCC_PEER;
|
|
4549
5231
|
delete process.env.CLAUDE_CODE_SESSION_ID;
|
|
4550
5232
|
delete process.env.CLAUDECODE;
|
|
@@ -4783,12 +5465,25 @@ async function syntaxAndHelp() {
|
|
|
4783
5465
|
if (!taskHelp.includes('task next [--peer ID] [--force]') ||
|
|
4784
5466
|
!taskHelp.includes('[--count N]') ||
|
|
4785
5467
|
!taskHelp.includes('existing claimed/running/review/blocked task') ||
|
|
5468
|
+
!taskHelp.includes('task dispatch --to ID --title TEXT') ||
|
|
5469
|
+
!taskHelp.includes('task dispatch --to ID --id N') ||
|
|
5470
|
+
!taskHelp.includes('message-only') ||
|
|
5471
|
+
!taskHelp.includes('running managed Claude/Codex terminal') ||
|
|
4786
5472
|
!taskHelp.includes('task takeover [--peer ID] --id N --reason TEXT') ||
|
|
4787
5473
|
!taskHelp.includes('[--policy any|blocked|stale|blocked-or-stale]') ||
|
|
4788
5474
|
!taskHelp.includes('task claim [--peer ID] --id N[,N]') ||
|
|
4789
5475
|
!taskHelp.includes('task create --title TEXT --parent N')) {
|
|
4790
5476
|
fail(`task help missing current-task task next semantics:\n${taskHelp}`);
|
|
4791
5477
|
}
|
|
5478
|
+
for (const expected of [
|
|
5479
|
+
"if (sub === 'dispatch') return taskDispatch",
|
|
5480
|
+
'async function taskDispatch(ctx, args)',
|
|
5481
|
+
"addEvent(eventDb, 'task.dispatched'",
|
|
5482
|
+
'function sessionLooksProviderInteractive(session)',
|
|
5483
|
+
'!customMessage && !sessionLooksProviderInteractive(session)'
|
|
5484
|
+
]) {
|
|
5485
|
+
if (!hccSource.includes(expected)) fail(`task dispatch source guard missing: ${expected}`);
|
|
5486
|
+
}
|
|
4792
5487
|
const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
|
|
4793
5488
|
if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
|
|
4794
5489
|
fail(`team help missing expected content:\n${teamHelp}`);
|
|
@@ -4817,6 +5512,19 @@ async function syntaxAndHelp() {
|
|
|
4817
5512
|
!tmuxHelp.includes('deletion requires --yes')) {
|
|
4818
5513
|
fail(`tmux help missing DB-backed gc semantics:\n${tmuxHelp}`);
|
|
4819
5514
|
}
|
|
5515
|
+
const shimHelp = run(process.execPath, [hccBin, 'shim', '--help']);
|
|
5516
|
+
if (!shimHelp.includes('hcc shim') ||
|
|
5517
|
+
!shimHelp.includes('shim install') ||
|
|
5518
|
+
!shimHelp.includes('shim status') ||
|
|
5519
|
+
!shimHelp.includes('shell PATH entry')) {
|
|
5520
|
+
fail(`shim help missing maintenance command content:\n${shimHelp}`);
|
|
5521
|
+
}
|
|
5522
|
+
const installHooksHelp = run(process.execPath, [hccBin, 'install-hooks', '--help']);
|
|
5523
|
+
if (!installHooksHelp.includes('hcc install-hooks') ||
|
|
5524
|
+
!installHooksHelp.includes('install-hooks --status') ||
|
|
5525
|
+
!installHooksHelp.includes('install-hooks --uninstall')) {
|
|
5526
|
+
fail(`install-hooks help missing maintenance command content:\n${installHooksHelp}`);
|
|
5527
|
+
}
|
|
4820
5528
|
const gcHelp = run(process.execPath, [hccBin, 'gc', '--help']);
|
|
4821
5529
|
if (!gcHelp.includes('hcc gc') ||
|
|
4822
5530
|
!gcHelp.includes('gc [--older-than DAYS] [--yes]') ||
|
|
@@ -4864,7 +5572,7 @@ async function syntaxAndHelp() {
|
|
|
4864
5572
|
}
|
|
4865
5573
|
|
|
4866
5574
|
function uninstallWorkflow() {
|
|
4867
|
-
log('[
|
|
5575
|
+
log('[13/13] maintenance cleanup');
|
|
4868
5576
|
const uninstallRoot = path.join(os.tmpdir(), `hcc-reg-uninstall-root-${testId}`);
|
|
4869
5577
|
const uninstallHome = path.join(os.tmpdir(), `hcc-reg-uninstall-home-${testId}`);
|
|
4870
5578
|
fs.mkdirSync(uninstallRoot, { recursive: true });
|
|
@@ -4886,11 +5594,16 @@ function uninstallWorkflow() {
|
|
|
4886
5594
|
ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
|
|
4887
5595
|
ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
|
|
4888
5596
|
ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));
|
|
5597
|
+
const uninstallBashrc = path.join(uninstallHome, '.bashrc');
|
|
5598
|
+
if (!fs.readFileSync(uninstallBashrc, 'utf8').includes('.hcc-shims')) {
|
|
5599
|
+
fail('setup did not install shim PATH entry before uninstall workflow');
|
|
5600
|
+
}
|
|
4889
5601
|
|
|
4890
5602
|
const kept = run(process.execPath, [hccBin, '--root', uninstallRoot, 'uninstall'], { env: uninstallEnv });
|
|
4891
5603
|
if (!kept.includes('project data kept')) fail(`uninstall did not keep project data by default:\n${kept}`);
|
|
4892
5604
|
if (!fs.existsSync(path.join(uninstallRoot, '.hello-cc', 'mesh.db'))) fail('default uninstall removed project db');
|
|
4893
5605
|
if (fs.existsSync(path.join(uninstallHome, '.hcc-shims', 'claude'))) fail('default uninstall did not remove shim');
|
|
5606
|
+
if (fs.readFileSync(uninstallBashrc, 'utf8').includes('.hcc-shims')) fail('default uninstall did not remove shim PATH entry');
|
|
4894
5607
|
if (!run(process.execPath, [hccBin, '--root', uninstallRoot, 'install-hooks', '--status'], { env: uninstallEnv }).includes('claude=no codex=no')) {
|
|
4895
5608
|
fail('default uninstall did not remove hooks');
|
|
4896
5609
|
}
|
|
@@ -4918,7 +5631,7 @@ async function main() {
|
|
|
4918
5631
|
process.once('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
4919
5632
|
|
|
4920
5633
|
await setupRegression();
|
|
4921
|
-
log('[2/
|
|
5634
|
+
log('[2/13] runtime');
|
|
4922
5635
|
startRuntime();
|
|
4923
5636
|
await waitRuntime();
|
|
4924
5637
|
hcc(['peer', 'list']);
|
|
@@ -4930,15 +5643,26 @@ async function main() {
|
|
|
4930
5643
|
await askBroadcastWorkflow();
|
|
4931
5644
|
await downGcPackWorkflow();
|
|
4932
5645
|
oldNameScan();
|
|
5646
|
+
identityEnforcementWorkflow();
|
|
4933
5647
|
await syntaxAndHelp();
|
|
4934
5648
|
uninstallWorkflow();
|
|
5649
|
+
assertNoRealProjectRegistryLeak();
|
|
5650
|
+
cleanup();
|
|
5651
|
+
assertNoRegressionTmuxSessionLeak();
|
|
4935
5652
|
log('FULL_REGRESSION_OK');
|
|
4936
5653
|
}
|
|
4937
5654
|
|
|
4938
5655
|
main().catch((err) => {
|
|
5656
|
+
try { assertNoRealProjectRegistryLeak(); } catch (leakErr) {
|
|
5657
|
+
process.stderr.write(`${leakErr.stack || leakErr.message}\n`);
|
|
5658
|
+
}
|
|
4939
5659
|
cleanup();
|
|
4940
5660
|
process.stderr.write(`${err.stack || err.message}\n`);
|
|
4941
5661
|
process.exit(1);
|
|
4942
5662
|
}).finally(() => {
|
|
4943
|
-
|
|
5663
|
+
try {
|
|
5664
|
+
assertNoRealProjectRegistryLeak();
|
|
5665
|
+
} finally {
|
|
5666
|
+
cleanup();
|
|
5667
|
+
}
|
|
4944
5668
|
});
|