@logicseek/hello-cc 0.1.6 → 0.1.7

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.
@@ -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']
@@ -72,6 +75,10 @@ function tmuxManagedSession(projectRoot, peer) {
72
75
  return `hcc-${shortHash(projectRoot)}-${sanitizePeerPart(peer, 'peer')}`.slice(0, 80);
73
76
  }
74
77
 
78
+ function tmuxManagedSessionPrefix(projectRoot) {
79
+ return `hcc-${shortHash(projectRoot)}-`;
80
+ }
81
+
75
82
  function run(command, args, options = {}) {
76
83
  const result = spawnSync(command, args, {
77
84
  cwd: options.cwd || repoRoot,
@@ -140,6 +147,33 @@ function providerBindingRows(provider, sessionName) {
140
147
  `).all(provider, sessionName));
141
148
  }
142
149
 
150
+ function parsePayloadJson(value) {
151
+ try {
152
+ const parsed = JSON.parse(value || '{}');
153
+ return parsed && typeof parsed === 'object' ? parsed : {};
154
+ } catch {
155
+ return {};
156
+ }
157
+ }
158
+
159
+ function eventPayloads(type, limit = 20, dbPath = path.join(root, '.hello-cc', 'mesh.db')) {
160
+ const db = new DatabaseSync(dbPath);
161
+ try {
162
+ return db.prepare(`
163
+ SELECT actor, payload
164
+ FROM events
165
+ WHERE type = ?
166
+ ORDER BY id DESC
167
+ LIMIT ?
168
+ `).all(type, limit).map((row) => ({
169
+ actor: row.actor,
170
+ payload: parsePayloadJson(row.payload)
171
+ }));
172
+ } finally {
173
+ db.close();
174
+ }
175
+ }
176
+
143
177
  function peerBindingIndexNames(dbPath = path.join(root, '.hello-cc', 'mesh.db')) {
144
178
  const db = new DatabaseSync(dbPath);
145
179
  try {
@@ -773,6 +807,48 @@ function tmuxAvailable() {
773
807
  return runMaybe('tmux', ['-V']).status === 0;
774
808
  }
775
809
 
810
+ function tmuxCleanupEndpoints() {
811
+ if (!realTmuxBin) return [{ label: 'default', command: 'tmux', args: [] }];
812
+ return [
813
+ { label: 'test-socket', command: realTmuxBin, args: ['-L', tmuxSocketName] },
814
+ { label: 'default', command: realTmuxBin, args: [] }
815
+ ];
816
+ }
817
+
818
+ function runMaybeTmuxEndpoint(endpoint, args) {
819
+ return runMaybe(endpoint.command, [...endpoint.args, ...args]);
820
+ }
821
+
822
+ function listTmuxSessionsMatching(prefixes, endpoint) {
823
+ const output = runMaybeTmuxEndpoint(endpoint, ['list-sessions', '-F', '#{session_name}']);
824
+ if (output.status !== 0) return [];
825
+ return (output.stdout || '')
826
+ .split('\n')
827
+ .map((line) => line.trim())
828
+ .filter(Boolean)
829
+ .filter((session) => prefixes.some((prefix) => session.startsWith(prefix)));
830
+ }
831
+
832
+ function killTmuxSessionOnEndpoint(endpoint, session) {
833
+ runMaybeTmuxEndpoint(endpoint, ['kill-session', '-t', session]);
834
+ }
835
+
836
+ function assertNoRegressionTmuxSessionLeak() {
837
+ const prefixes = [
838
+ tmuxManagedSessionPrefix(root),
839
+ tmuxManagedSessionPrefix(secondProjectRoot)
840
+ ];
841
+ const leaks = [];
842
+ for (const endpoint of tmuxCleanupEndpoints()) {
843
+ for (const session of listTmuxSessionsMatching(prefixes, endpoint)) {
844
+ leaks.push(`${endpoint.label}:${session}`);
845
+ }
846
+ }
847
+ if (leaks.length) {
848
+ fail(`regression leaked hcc-managed tmux sessions:\n${leaks.join('\n')}`);
849
+ }
850
+ }
851
+
776
852
  function trackTmuxPane(pane) {
777
853
  if (!pane) return;
778
854
  const result = runMaybe('tmux', ['display-message', '-p', '-t', pane, '#S']);
@@ -1139,11 +1215,20 @@ function cleanup() {
1139
1215
  if (runtimePid) {
1140
1216
  try { process.kill(runtimePid, 'SIGTERM'); } catch {}
1141
1217
  }
1142
- if (tmuxStarted) {
1143
- runMaybe('tmux', ['kill-session', '-t', tmuxSession]);
1144
- }
1145
- for (const session of managedTmuxSessions) {
1146
- runMaybe('tmux', ['kill-session', '-t', session]);
1218
+ const managedPrefixes = [
1219
+ tmuxManagedSessionPrefix(root),
1220
+ tmuxManagedSessionPrefix(secondProjectRoot)
1221
+ ];
1222
+ for (const endpoint of tmuxCleanupEndpoints()) {
1223
+ for (const session of listTmuxSessionsMatching(managedPrefixes, endpoint)) {
1224
+ managedTmuxSessions.add(session);
1225
+ }
1226
+ if (tmuxStarted) {
1227
+ killTmuxSessionOnEndpoint(endpoint, tmuxSession);
1228
+ }
1229
+ for (const session of managedTmuxSessions) {
1230
+ killTmuxSessionOnEndpoint(endpoint, session);
1231
+ }
1147
1232
  }
1148
1233
  if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
1149
1234
  runMaybe('tmux', ['kill-server']);
@@ -1153,8 +1238,36 @@ function cleanup() {
1153
1238
  }
1154
1239
  }
1155
1240
 
1241
+ function assertNoRealProjectRegistryLeak() {
1242
+ const after = fs.existsSync(realRegistryFile)
1243
+ ? fs.readFileSync(realRegistryFile, 'utf8')
1244
+ : null;
1245
+ if (!after) return;
1246
+
1247
+ let parsed = null;
1248
+ try {
1249
+ parsed = JSON.parse(after);
1250
+ } catch {
1251
+ fail(`regression changed real project registry with invalid JSON: ${realRegistryFile}`);
1252
+ }
1253
+ const leakedRoots = (parsed?.projects || [])
1254
+ .map((project) => String(project?.root || ''))
1255
+ .filter((projectRoot) => {
1256
+ if (!projectRoot) return false;
1257
+ if (projectRoot === root || projectRoot === home || projectRoot === fakeBin || projectRoot === outDir) return true;
1258
+ return projectRoot.startsWith(`${root}${path.sep}`) ||
1259
+ projectRoot.startsWith(`${home}${path.sep}`) ||
1260
+ projectRoot.startsWith(`${fakeBin}${path.sep}`) ||
1261
+ projectRoot.startsWith(`${outDir}${path.sep}`) ||
1262
+ projectRoot.includes(`hcc-reg-${testId}`);
1263
+ });
1264
+ if (leakedRoots.length) {
1265
+ fail(`regression leaked temporary projects into real registry ${realRegistryFile}:\n${leakedRoots.join('\n')}`);
1266
+ }
1267
+ }
1268
+
1156
1269
  async function setupRegression() {
1157
- log('[1/12] web bootstrap/hooks/shims');
1270
+ log('[1/13] web bootstrap/hooks/shims');
1158
1271
  writeFakeTools();
1159
1272
  fs.mkdirSync(root, { recursive: true });
1160
1273
  fs.mkdirSync(home, { recursive: true });
@@ -1338,7 +1451,7 @@ async function setupRegression() {
1338
1451
  }
1339
1452
 
1340
1453
  async function dbWorkflow() {
1341
- log('[3/12] db workflow');
1454
+ log('[3/13] db workflow');
1342
1455
  hcc(['register', '--peer', 'human', '--kind', 'human', '--role', 'operator']);
1343
1456
  const created = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'full regression task', '--body', 'exercise hcc bus']);
1344
1457
  const taskMatch = created.match(/created task #(\d+):/);
@@ -1505,7 +1618,8 @@ async function dbWorkflow() {
1505
1618
  !staleLivenessState.automation.next_action.argv.includes('stale')) {
1506
1619
  fail(`state automation did not suggest stale takeover:\n${JSON.stringify(staleLivenessState.automation, null, 2)}`);
1507
1620
  }
1508
- hcc(['task', 'update', '--peer', 'human', '--force', '--id', staleLivenessTaskId, '--status', 'abandoned', '--summary', 'stale liveness cleanup']);
1621
+ hcc(['task', 'takeover', '--peer', 'human', '--id', staleLivenessTaskId, '--reason', 'stale liveness cleanup', '--policy', 'stale', '--stale-after', '60']);
1622
+ hcc(['task', 'update', '--peer', 'human', '--id', staleLivenessTaskId, '--status', 'abandoned', '--summary', 'stale liveness cleanup']);
1509
1623
  hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
1510
1624
  hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
1511
1625
  if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
@@ -1727,8 +1841,8 @@ async function dbWorkflow() {
1727
1841
  }
1728
1842
 
1729
1843
  async function multiProjectWebWorkflow() {
1730
- log('[4/12] multi-project web');
1731
- const otherRoot = path.join(root, 'second-project');
1844
+ log('[4/13] multi-project web');
1845
+ const otherRoot = secondProjectRoot;
1732
1846
  fs.mkdirSync(otherRoot, { recursive: true });
1733
1847
  const output = hccFrom(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], otherRoot);
1734
1848
  if (!output.includes('web already running in background')) fail(`second project did not reuse global web:\n${output}`);
@@ -1907,6 +2021,72 @@ async function multiProjectWebWorkflow() {
1907
2021
  fail(`sessions API did not expose unknown provider binding without marking it shared/known:\n${JSON.stringify(otherShellSession, null, 2)}`);
1908
2022
  }
1909
2023
 
2024
+ const startProvider = async (payload) => {
2025
+ const response = await runtimeFetch('/api/sessions', {
2026
+ method: 'POST',
2027
+ headers: { 'Content-Type': 'application/json' },
2028
+ body: JSON.stringify({
2029
+ env: {
2030
+ HOME: home,
2031
+ PATH: env.PATH,
2032
+ SHELL: process.env.SHELL || 'bash',
2033
+ HCC_FAKE_STAY_ALIVE: '1'
2034
+ },
2035
+ ...payload
2036
+ })
2037
+ }, { root });
2038
+ const json = await response.json();
2039
+ if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
2040
+ return json.session;
2041
+ };
2042
+ const stopSession = async (id, payload = null) => {
2043
+ const options = payload
2044
+ ? {
2045
+ method: 'POST',
2046
+ headers: { 'Content-Type': 'application/json' },
2047
+ body: JSON.stringify(payload)
2048
+ }
2049
+ : { method: 'POST' };
2050
+ const response = await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, options, { root });
2051
+ const json = await response.json();
2052
+ if (!response.ok) fail(`web provider session stop failed: ${JSON.stringify(json)}`);
2053
+ return json.session;
2054
+ };
2055
+
2056
+ const auditSpoofSession = await startProvider({
2057
+ kind: 'shell',
2058
+ command: 'bash --noprofile --norc',
2059
+ auditActorPeer: 'spoofed-web-actor',
2060
+ auditSource: 'spoofed-source'
2061
+ });
2062
+ const auditSpoofPeer = auditSpoofSession.peer_id || auditSpoofSession.id;
2063
+ const attachAudit = eventPayloads('tmux.session.attached', 80)
2064
+ .find((event) => event.payload?.target_peer === auditSpoofPeer);
2065
+ if (!attachAudit ||
2066
+ attachAudit.actor !== 'web' ||
2067
+ attachAudit.payload.actor_peer !== 'web' ||
2068
+ attachAudit.payload.source !== 'web' ||
2069
+ attachAudit.payload.actor_peer === 'spoofed-web-actor' ||
2070
+ attachAudit.payload.source === 'spoofed-source') {
2071
+ fail(`web start audit allowed spoofed actor/source:\n${JSON.stringify({ auditSpoofSession, attachAudit }, null, 2)}`);
2072
+ }
2073
+ await stopSession(auditSpoofSession.id, {
2074
+ auditActorPeer: 'spoofed-stop-actor',
2075
+ auditSource: 'spoofed-source'
2076
+ });
2077
+ const stopAudit = eventPayloads('web.session.stop_requested', 80)
2078
+ .find((event) => event.payload?.target_peer === auditSpoofPeer);
2079
+ if (!stopAudit ||
2080
+ stopAudit.actor !== 'web' ||
2081
+ stopAudit.payload.actor_peer !== 'web' ||
2082
+ stopAudit.payload.target_peer !== auditSpoofPeer ||
2083
+ stopAudit.payload.source !== 'web' ||
2084
+ stopAudit.payload.admin !== true ||
2085
+ stopAudit.payload.actor_peer === 'spoofed-stop-actor' ||
2086
+ stopAudit.payload.source === 'spoofed-source') {
2087
+ fail(`web stop audit allowed spoofed actor/source:\n${JSON.stringify({ auditSpoofSession, stopAudit }, null, 2)}`);
2088
+ }
2089
+
1910
2090
  hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
1911
2091
  hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
1912
2092
  const actionStatus = await (await runtimeFetch('/api/peers/web-action-peer/actions/status', {}, { root })).json();
@@ -1924,10 +2104,28 @@ async function multiProjectWebWorkflow() {
1924
2104
  const taskOutput = hcc(['task', 'create', '--title', 'web action task', '--body', 'claim through web action']);
1925
2105
  const taskMatch = taskOutput.match(/created task #(\d+):/);
1926
2106
  if (!taskMatch) fail(`cannot parse web action task id:\n${taskOutput}`);
1927
- const actionNext = await (await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
2107
+ const rejectedActionNextResponse = await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
1928
2108
  method: 'POST',
1929
2109
  headers: { 'Content-Type': 'application/json' },
1930
2110
  body: '{}'
2111
+ }, { root });
2112
+ const rejectedActionNext = await rejectedActionNextResponse.json();
2113
+ if (rejectedActionNextResponse.ok || rejectedActionNext.error?.code !== 'PEER_IDENTITY_REQUIRED') {
2114
+ fail(`web task-next accepted unmanaged peer identity:\n${JSON.stringify(rejectedActionNext, null, 2)}`);
2115
+ }
2116
+ const unmanagedTaskAfterReject = hccJson(['task', 'list', '--all']).find((task) => String(task.id) === taskMatch[1]);
2117
+ if (!unmanagedTaskAfterReject || unmanagedTaskAfterReject.owner) {
2118
+ fail(`rejected web task-next still changed task owner:\n${JSON.stringify(unmanagedTaskAfterReject, null, 2)}`);
2119
+ }
2120
+
2121
+ const managedActionSession = await startProvider({ kind: 'shell', command: 'bash --noprofile --norc' });
2122
+ if (!managedActionSession.action_token) {
2123
+ fail(`managed web session did not include action token:\n${JSON.stringify(managedActionSession, null, 2)}`);
2124
+ }
2125
+ const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
2126
+ method: 'POST',
2127
+ headers: { 'Content-Type': 'application/json' },
2128
+ body: JSON.stringify({ action_token: managedActionSession.action_token })
1931
2129
  }, { root })).json();
1932
2130
  if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
1933
2131
  fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
@@ -1937,31 +2135,51 @@ async function multiProjectWebWorkflow() {
1937
2135
  headers: { 'Content-Type': 'application/json' },
1938
2136
  body: JSON.stringify({ renew_locks: true })
1939
2137
  }, { root })).json();
1940
- if (!actionHeartbeat.ok || actionHeartbeat.action !== 'heartbeat' || actionHeartbeat.data?.peer !== 'web-action-peer') {
1941
- fail(`web heartbeat action did not return structured result:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
2138
+ if (actionHeartbeat.ok || actionHeartbeat.error?.code !== 'PEER_IDENTITY_REQUIRED') {
2139
+ fail(`web heartbeat accepted unmanaged peer identity:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
1942
2140
  }
1943
2141
 
1944
- const startProvider = async (payload) => {
1945
- const response = await runtimeFetch('/api/sessions', {
1946
- method: 'POST',
1947
- headers: { 'Content-Type': 'application/json' },
1948
- body: JSON.stringify({
1949
- env: {
1950
- HOME: home,
1951
- PATH: env.PATH,
1952
- SHELL: process.env.SHELL || 'bash',
1953
- HCC_FAKE_STAY_ALIVE: '1'
1954
- },
1955
- ...payload
1956
- })
1957
- }, { root });
1958
- const json = await response.json();
1959
- if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
1960
- return json.session;
1961
- };
1962
- const stopSession = async (id) => {
1963
- await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }, { root });
1964
- };
2142
+ const ownerTask = hcc(['task', 'create', '--from', 'web-lock-owner', '--title', 'web lock owner task']);
2143
+ const ownerTaskMatch = ownerTask.match(/created task #(\d+):/);
2144
+ if (!ownerTaskMatch) fail(`cannot parse web lock owner task id:\n${ownerTask}`);
2145
+ hcc(['task', 'claim', '--peer', 'web-lock-owner', '--id', ownerTaskMatch[1]]);
2146
+ const rejectedLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/lock-acquire`, {
2147
+ method: 'POST',
2148
+ headers: { 'Content-Type': 'application/json' },
2149
+ body: JSON.stringify({
2150
+ action_token: managedActionSession.action_token,
2151
+ resource: 'web/identity-spoof-lock',
2152
+ task: Number(ownerTaskMatch[1])
2153
+ })
2154
+ }, { root });
2155
+ const rejectedLock = await rejectedLockResponse.json();
2156
+ if (rejectedLockResponse.ok || rejectedLock.error?.code !== 'TASK_OWNED') {
2157
+ fail(`web lock-acquire did not enforce task owner:\n${JSON.stringify(rejectedLock, null, 2)}`);
2158
+ }
2159
+ const lockRowsAfterReject = hccJson(['lock', 'list', '--all']);
2160
+ if (lockRowsAfterReject.some((lock) => lock.resource === 'web/identity-spoof-lock')) {
2161
+ fail(`rejected web lock-acquire still inserted a lock:\n${JSON.stringify(lockRowsAfterReject, null, 2)}`);
2162
+ }
2163
+ hcc(['task', 'done', '--peer', managedActionSession.peer_id || managedActionSession.id, '--id', taskMatch[1], '--summary', 'web action cleanup'], {
2164
+ env: { ...env, HCC_PEER: managedActionSession.peer_id || managedActionSession.id }
2165
+ });
2166
+ hcc(['task', 'done', '--peer', 'web-lock-owner', '--id', ownerTaskMatch[1], '--summary', 'web lock owner cleanup'], {
2167
+ env: { ...env, HCC_PEER: 'web-lock-owner' }
2168
+ });
2169
+ await stopSession(managedActionSession.id);
2170
+
2171
+ hcc(['register', '--peer', 'detected-msg-peer', '--kind', 'codex', '--role', 'peer']);
2172
+ const spoofedDetectedMessage = await (await runtimeFetch('/api/detected/detected-msg-peer/msg', {
2173
+ method: 'POST',
2174
+ headers: { 'Content-Type': 'application/json' },
2175
+ body: JSON.stringify({ from: 'web-action-peer', body: 'detected sender spoof attempt' })
2176
+ }, { root })).json();
2177
+ if (!spoofedDetectedMessage.ok) fail(`detected message send failed:\n${JSON.stringify(spoofedDetectedMessage, null, 2)}`);
2178
+ const detectedInbox = hccJson(['msg', 'inbox', '--peer', 'detected-msg-peer', '--all']);
2179
+ const detectedMsg = detectedInbox.find((message) => message.body === 'detected sender spoof attempt');
2180
+ if (!detectedMsg || detectedMsg.sender !== 'web') {
2181
+ fail(`detected message sender spoof was not forced to web:\n${JSON.stringify(detectedInbox, null, 2)}`);
2182
+ }
1965
2183
 
1966
2184
  const claudeResumeName = `web-claude-resume-${testId}`;
1967
2185
  const claudeResume = await startProvider({ kind: 'claude', mode: 'resume', resume: claudeResumeName });
@@ -2069,11 +2287,11 @@ async function multiProjectWebWorkflow() {
2069
2287
 
2070
2288
  async function tmuxBackedStartWorkflow() {
2071
2289
  if (!tmuxAvailable()) {
2072
- log('[5/12] tmux-backed start skipped (tmux not installed)');
2290
+ log('[5/13] tmux-backed start skipped (tmux not installed)');
2073
2291
  return;
2074
2292
  }
2075
2293
 
2076
- log('[5/12] tmux-backed start + websocket + restore');
2294
+ log('[5/13] tmux-backed start + websocket + restore');
2077
2295
  const file = path.join(outDir, 'pty-ok');
2078
2296
  const started = hcc(['peer', 'start', 'shell-a', '--kind', 'shell', '--', 'bash']);
2079
2297
  const pane = parsePane(started);
@@ -2178,11 +2396,11 @@ async function tmuxBackedStartWorkflow() {
2178
2396
 
2179
2397
  async function shimTmuxWorkflow() {
2180
2398
  if (!tmuxAvailable()) {
2181
- log('[6/12] shim tmux-backed launch skipped (tmux not installed)');
2399
+ log('[6/13] shim tmux-backed launch skipped (tmux not installed)');
2182
2400
  return;
2183
2401
  }
2184
2402
 
2185
- log('[6/12] shim tmux-backed launch');
2403
+ log('[6/13] shim tmux-backed launch');
2186
2404
  const shim = path.join(home, '.hcc-shims', 'claude');
2187
2405
  const codexShim = path.join(home, '.hcc-shims', 'codex');
2188
2406
  const claudeVersion = run(shim, ['--version'], { cwd: root, env });
@@ -2292,11 +2510,11 @@ async function shimTmuxWorkflow() {
2292
2510
  async function tmuxWorkflow() {
2293
2511
  const tmuxVersion = runMaybe('tmux', ['-V']);
2294
2512
  if (tmuxVersion.status !== 0) {
2295
- log('[7/12] tmux skipped (tmux not installed)');
2513
+ log('[7/13] tmux skipped (tmux not installed)');
2296
2514
  return;
2297
2515
  }
2298
2516
 
2299
- log('[7/12] tmux attach + websocket + force');
2517
+ log('[7/13] tmux attach + websocket + force');
2300
2518
  run('tmux', ['new-session', '-d', '-s', tmuxSession, '-c', os.tmpdir(), 'bash --noprofile --norc']);
2301
2519
  tmuxStarted = true;
2302
2520
  const pane = run('tmux', ['display-message', '-p', '-t', `${tmuxSession}:0.0`, '#{pane_id}']).trim();
@@ -2318,11 +2536,11 @@ async function tmuxWorkflow() {
2318
2536
 
2319
2537
  async function askBroadcastWorkflow() {
2320
2538
  if (!tmuxAvailable()) {
2321
- log('[8/12] ask/broadcast injection skipped (tmux not installed)');
2539
+ log('[8/13] ask/broadcast injection skipped (tmux not installed)');
2322
2540
  return;
2323
2541
  }
2324
2542
 
2325
- log('[8/12] ask/broadcast injection');
2543
+ log('[8/13] ask/broadcast injection');
2326
2544
  const askFile = path.join(outDir, 'ask-ok');
2327
2545
  parsePane(hcc(['peer', 'start', 'shell-b', '--kind', 'shell', '--', 'bash']));
2328
2546
  hcc(['ask', 'shell-b', `echo ASK_OK > ${askFile}`, '--from', 'human', '--inject']);
@@ -2334,7 +2552,7 @@ async function askBroadcastWorkflow() {
2334
2552
  }
2335
2553
 
2336
2554
  async function downGcPackWorkflow() {
2337
- log('[9/12] down/gc/pack');
2555
+ log('[9/13] down/gc/pack');
2338
2556
  hccMaybe(['peer', 'stop', 'shell-a']);
2339
2557
  hccMaybe(['peer', 'stop', 'shell-b']);
2340
2558
  await stopRuntime();
@@ -2391,7 +2609,7 @@ async function downGcPackWorkflow() {
2391
2609
  }
2392
2610
 
2393
2611
  function oldNameScan() {
2394
- log('[10/12] old-name scan');
2612
+ log('[10/13] old-name scan');
2395
2613
  const oldNamePattern = [
2396
2614
  'agent' + 'mesh',
2397
2615
  'Agent' + 'mesh',
@@ -2414,8 +2632,125 @@ function oldNameScan() {
2414
2632
  if (rg.status !== 1) fail(`rg failed:\n${rg.stderr || rg.stdout}`);
2415
2633
  }
2416
2634
 
2635
+ function identityEnforcementWorkflow() {
2636
+ log('[11/13] identity enforcement');
2637
+ const identityRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-identity-root-${testId}-`));
2638
+ const identityHome = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-identity-home-${testId}-`));
2639
+ const identityEnv = {
2640
+ ...env,
2641
+ HOME: identityHome,
2642
+ HCC_PEER: 'real-peer'
2643
+ };
2644
+ delete identityEnv.HCC_ROOT;
2645
+ delete identityEnv.HCC_DB;
2646
+ try {
2647
+ hccFrom(['register', '--peer', 'fake-peer', '--kind', 'codex', '--role', 'peer'], identityRoot, { env: identityEnv });
2648
+ const envOut = hccFrom(['env', '--peer', 'fake-peer'], identityRoot, { env: identityEnv });
2649
+ if (!envOut.includes('HCC_PEER=fake-peer')) {
2650
+ fail(`env did not export explicit target peer:\n${envOut}`);
2651
+ }
2652
+ const created = hccFrom(['task', 'create', '--from', 'fake-peer', '--title', 'identity guard'], identityRoot, { env: identityEnv });
2653
+ const match = created.match(/created task #(\d+):/);
2654
+ if (!match) fail(`cannot parse identity task id:\n${created}`);
2655
+ const taskId = match[1];
2656
+ hccFrom(['task', 'claim', '--peer', 'fake-peer', '--id', taskId], identityRoot, { env: identityEnv });
2657
+
2658
+ const dbPath = path.join(identityRoot, '.hello-cc', 'mesh.db');
2659
+ const db = new DatabaseSync(dbPath);
2660
+ try {
2661
+ const task = db.prepare('SELECT owner, created_by FROM tasks WHERE id = ?').get(Number(taskId));
2662
+ const fakePeer = db.prepare('SELECT id FROM peers WHERE id = ?').get('fake-peer');
2663
+ if (task?.owner !== 'real-peer' || task?.created_by !== 'real-peer' || fakePeer) {
2664
+ fail(`system peer identity was not enforced:\n${JSON.stringify({ task, fakePeer }, null, 2)}`);
2665
+ }
2666
+ db.prepare(`
2667
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
2668
+ VALUES ('inspect-peer', 'codex', 'peer', ?, '', NULL, 'working', '', 1, 1)
2669
+ `).run(identityRoot);
2670
+ db.prepare(`
2671
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
2672
+ VALUES ('owner-peer', 'codex', 'peer', ?, '', NULL, 'working', '', 1, 1)
2673
+ `).run(identityRoot);
2674
+ db.prepare(`
2675
+ INSERT INTO tasks(title, body, status, assignee, owner, priority, created_by, created_at, updated_at)
2676
+ VALUES ('owned identity guard', '', 'claimed', NULL, 'owner-peer', 100, 'owner-peer', 1, 1)
2677
+ `).run();
2678
+ } finally {
2679
+ db.close();
2680
+ }
2681
+
2682
+ const statusOut = hccFrom(['status', '--peer', 'inspect-peer'], identityRoot, { env: identityEnv });
2683
+ if (!statusOut.includes('inbox(inspect-peer)')) {
2684
+ fail(`status did not inspect explicit target peer:\n${statusOut}`);
2685
+ }
2686
+ const stateOut = hccFrom(['state', '--peer', 'inspect-peer'], identityRoot, { env: identityEnv });
2687
+ if (!stateOut.includes('peer: inspect-peer')) {
2688
+ fail(`state did not inspect explicit target peer:\n${stateOut}`);
2689
+ }
2690
+
2691
+ const owned = hccFromMaybe(['task', 'done', '--peer', 'owner-peer', '--id', '2', '--summary', 'should not pass'], identityRoot, { env: identityEnv });
2692
+ if (owned.status === 0 || !`${owned.stdout}\n${owned.stderr}`.includes('Task #2 is owned by owner-peer')) {
2693
+ fail(`owned task mutation was not rejected:\nstdout=${owned.stdout}\nstderr=${owned.stderr}`);
2694
+ }
2695
+ const forcedOwned = hccFromMaybe(['task', 'done', '--force', '--peer', 'owner-peer', '--id', '2', '--summary', 'should not pass'], identityRoot, { env: identityEnv });
2696
+ if (forcedOwned.status === 0 || !`${forcedOwned.stdout}\n${forcedOwned.stderr}`.includes('Task #2 is owned by owner-peer')) {
2697
+ fail(`forced owned task mutation was not rejected:\nstdout=${forcedOwned.stdout}\nstderr=${forcedOwned.stderr}`);
2698
+ }
2699
+ const db2 = new DatabaseSync(dbPath);
2700
+ try {
2701
+ const task = db2.prepare('SELECT owner, status FROM tasks WHERE id = 2').get();
2702
+ const notice = db2.prepare(`
2703
+ SELECT sender, recipient, kind
2704
+ FROM messages
2705
+ WHERE task_id = 2 AND kind = 'task.owner-conflict'
2706
+ `).get();
2707
+ if (task?.owner !== 'owner-peer' || task?.status !== 'claimed' ||
2708
+ !notice || notice.sender !== 'real-peer' || notice.recipient !== 'owner-peer') {
2709
+ fail(`owned task mutation did not preserve owner and notify:\n${JSON.stringify({ task, notice }, null, 2)}`);
2710
+ }
2711
+ } finally {
2712
+ db2.close();
2713
+ }
2714
+
2715
+ hccFrom(['run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: identityEnv });
2716
+ const db3 = new DatabaseSync(dbPath);
2717
+ try {
2718
+ const realRunner = db3.prepare('SELECT id, status FROM peers WHERE id = ?').get('real-peer');
2719
+ const fakeRunner = db3.prepare('SELECT id FROM peers WHERE id = ?').get('fake-runner');
2720
+ if (!realRunner || realRunner.status !== 'exited' || fakeRunner) {
2721
+ fail(`run did not enforce system peer identity:\n${JSON.stringify({ realRunner, fakeRunner }, null, 2)}`);
2722
+ }
2723
+ } finally {
2724
+ db3.close();
2725
+ }
2726
+
2727
+ hccFrom(['peer', 'stop', 'fake-target'], identityRoot, { env: identityEnv });
2728
+ const stopRequested = eventPayloads('peer.stop.requested', 5, dbPath)
2729
+ .find((event) => event.payload?.target_peer === 'fake-target');
2730
+ if (!stopRequested ||
2731
+ stopRequested.actor !== 'real-peer' ||
2732
+ stopRequested.payload.actor_peer !== 'real-peer' ||
2733
+ stopRequested.payload.source !== 'cli' ||
2734
+ stopRequested.payload.admin !== true) {
2735
+ fail(`peer stop request did not record real actor and target:\n${JSON.stringify(stopRequested, null, 2)}`);
2736
+ }
2737
+ const stoppedFallback = eventPayloads('peer.stopped', 5, dbPath)
2738
+ .find((event) => event.payload?.target_peer === 'fake-target');
2739
+ if (!stoppedFallback ||
2740
+ stoppedFallback.actor !== 'real-peer' ||
2741
+ stoppedFallback.payload.actor_peer !== 'real-peer' ||
2742
+ stoppedFallback.payload.source !== 'cli' ||
2743
+ stoppedFallback.payload.admin !== true) {
2744
+ fail(`peer stop fallback did not record real actor and target:\n${JSON.stringify(stoppedFallback, null, 2)}`);
2745
+ }
2746
+ } finally {
2747
+ try { fs.rmSync(identityRoot, { recursive: true, force: true }); } catch {}
2748
+ try { fs.rmSync(identityHome, { recursive: true, force: true }); } catch {}
2749
+ }
2750
+ }
2751
+
2417
2752
  async function syntaxAndHelp() {
2418
- log('[11/12] syntax/help');
2753
+ log('[12/13] syntax/help');
2419
2754
  run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
2420
2755
  for (const file of libModuleFiles()) {
2421
2756
  run(process.execPath, ['--check', path.join(repoRoot, file)]);
@@ -2601,7 +2936,19 @@ async function syntaxAndHelp() {
2601
2936
  '} = createWebPeerActions({',
2602
2937
  'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
2603
2938
  "const readOnly = ['status', 'state', 'inbox'].includes(action)",
2604
- "sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
2939
+ 'resolveWebActionSession(reqCtx, peer, input, req)',
2940
+ 'action_token: session.actionToken || null',
2941
+ "const sender = 'web';",
2942
+ 'function auditPayload({ actor = null, target = null, source = ',
2943
+ 'payload.actor_peer = actor',
2944
+ 'payload.target_peer = target',
2945
+ "auditActorPeer: 'web'",
2946
+ "auditSource: 'web'",
2947
+ "addEvent(db, 'peer.start.requested'",
2948
+ "addEvent(db, 'peer.attach.requested'",
2949
+ "addEvent(db, 'peer.stop.requested'",
2950
+ "addEvent(eventDb, 'web.session.stop_requested'",
2951
+ "addEvent(db, 'tmux.session.gc'"
2605
2952
  ]) {
2606
2953
  if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
2607
2954
  }
@@ -2618,9 +2965,17 @@ async function syntaxAndHelp() {
2618
2965
  if (hccSource.includes(helper)) fail(`CLI still embeds web peer action helper: ${helper}`);
2619
2966
  }
2620
2967
  for (const expected of [
2968
+ 'function webMutationPeer(peer, input = {})',
2621
2969
  'function webPeerAction(projectCtx, peer, action, input = {})',
2970
+ 'function webAuditPayload(peer, input = {}, extra = {})',
2971
+ "source: 'web'",
2972
+ 'actor_peer: actorPeer',
2973
+ 'target_peer: peer',
2974
+ "throw new CliError('PEER_IDENTITY_MISMATCH'",
2975
+ "throw new CliError('PEER_IDENTITY_REQUIRED'",
2622
2976
  'claimNextTasksForPeer(db, peer, { force: Boolean(input.force), count })',
2623
2977
  'takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter, source: ',
2978
+ "assertTaskOwnerForMutation(db, peer, task, 'lock-acquire')",
2624
2979
  'const status = statusSummary(projectCtx, peer)',
2625
2980
  "normalized === 'task-next'",
2626
2981
  "normalized === 'lock-acquire'",
@@ -3232,11 +3587,12 @@ async function syntaxAndHelp() {
3232
3587
  ],
3233
3588
  events: [
3234
3589
  { 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 }
3590
+ { id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 },
3591
+ { 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 }
3236
3592
  ]
3237
3593
  }, 'peer-b');
3238
3594
  const timelineIds = timelineItems.map((item) => item.id);
3239
- if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8'])) {
3595
+ if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8', 'event:9'])) {
3240
3596
  fail(`timelineFromRows filtering/order changed: ${JSON.stringify(timelineItems)}`);
3241
3597
  }
3242
3598
  const inbound = timelineItems.find((item) => item.id === 'message:2');
@@ -4545,6 +4901,10 @@ async function syntaxAndHelp() {
4545
4901
  if (integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, {}, 'peer', 'shell').id !== 'env-peer') {
4546
4902
  fail('peer identity resolveCurrentPeer ignored HCC_PEER');
4547
4903
  }
4904
+ const correctedPeer = integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, { peer: 'manual-peer' }, 'peer', 'shell');
4905
+ if (correctedPeer.id !== 'env-peer' || correctedPeer.corrected_from !== 'manual-peer') {
4906
+ fail(`peer identity did not correct explicit peer to HCC_PEER: ${JSON.stringify(correctedPeer)}`);
4907
+ }
4548
4908
  delete process.env.HCC_PEER;
4549
4909
  delete process.env.CLAUDE_CODE_SESSION_ID;
4550
4910
  delete process.env.CLAUDECODE;
@@ -4864,7 +5224,7 @@ async function syntaxAndHelp() {
4864
5224
  }
4865
5225
 
4866
5226
  function uninstallWorkflow() {
4867
- log('[12/12] maintenance cleanup');
5227
+ log('[13/13] maintenance cleanup');
4868
5228
  const uninstallRoot = path.join(os.tmpdir(), `hcc-reg-uninstall-root-${testId}`);
4869
5229
  const uninstallHome = path.join(os.tmpdir(), `hcc-reg-uninstall-home-${testId}`);
4870
5230
  fs.mkdirSync(uninstallRoot, { recursive: true });
@@ -4918,7 +5278,7 @@ async function main() {
4918
5278
  process.once('SIGTERM', () => { cleanup(); process.exit(143); });
4919
5279
 
4920
5280
  await setupRegression();
4921
- log('[2/12] runtime');
5281
+ log('[2/13] runtime');
4922
5282
  startRuntime();
4923
5283
  await waitRuntime();
4924
5284
  hcc(['peer', 'list']);
@@ -4930,15 +5290,26 @@ async function main() {
4930
5290
  await askBroadcastWorkflow();
4931
5291
  await downGcPackWorkflow();
4932
5292
  oldNameScan();
5293
+ identityEnforcementWorkflow();
4933
5294
  await syntaxAndHelp();
4934
5295
  uninstallWorkflow();
5296
+ assertNoRealProjectRegistryLeak();
5297
+ cleanup();
5298
+ assertNoRegressionTmuxSessionLeak();
4935
5299
  log('FULL_REGRESSION_OK');
4936
5300
  }
4937
5301
 
4938
5302
  main().catch((err) => {
5303
+ try { assertNoRealProjectRegistryLeak(); } catch (leakErr) {
5304
+ process.stderr.write(`${leakErr.stack || leakErr.message}\n`);
5305
+ }
4939
5306
  cleanup();
4940
5307
  process.stderr.write(`${err.stack || err.message}\n`);
4941
5308
  process.exit(1);
4942
5309
  }).finally(() => {
4943
- cleanup();
5310
+ try {
5311
+ assertNoRealProjectRegistryLeak();
5312
+ } finally {
5313
+ cleanup();
5314
+ }
4944
5315
  });