@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +111 -23
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/dsh.md +2 -2
  7. package/docs/dsh.zh-CN.md +3 -3
  8. package/docs/native.md +84 -10
  9. package/docs/native.zh-CN.md +66 -5
  10. package/docs/web-handoff.zh-CN.md +11 -3
  11. package/lib/cli/app.mjs +42 -1
  12. package/lib/cli/commands/coordination.mjs +7 -2
  13. package/lib/cli/commands/dsh.mjs +1 -1
  14. package/lib/cli/commands/gc.mjs +9 -4
  15. package/lib/cli/commands/hook.mjs +6 -1
  16. package/lib/cli/commands/install.mjs +48 -6
  17. package/lib/cli/commands/lifecycle.mjs +14 -3
  18. package/lib/cli/commands/native.mjs +16 -9
  19. package/lib/cli/commands/query.mjs +15 -10
  20. package/lib/cli/commands/run.mjs +7 -6
  21. package/lib/core/coordination/results.mjs +27 -10
  22. package/lib/db/connection.mjs +135 -10
  23. package/lib/db/schema.mjs +59 -0
  24. package/lib/discover.mjs +4 -4
  25. package/lib/guidance.mjs +3 -2
  26. package/lib/integrations/codex-interactions.mjs +13 -11
  27. package/lib/integrations/dsh-collaboration.mjs +6 -12
  28. package/lib/integrations/dsh-cordis.mjs +17 -13
  29. package/lib/integrations/dsh.mjs +12 -9
  30. package/lib/integrations/hooks.mjs +132 -40
  31. package/lib/integrations/mcp-elicitation.mjs +116 -0
  32. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  33. package/lib/integrations/native/interactions.mjs +2 -1
  34. package/lib/integrations/peers/identity.mjs +42 -7
  35. package/lib/process/identity.mjs +15 -14
  36. package/lib/runtime/buffer-evidence.mjs +2 -1
  37. package/lib/runtime/native/service.mjs +11 -5
  38. package/lib/runtime/native/store.mjs +70 -18
  39. package/lib/runtime/paths.mjs +17 -2
  40. package/lib/runtime/private-file.mjs +76 -0
  41. package/lib/runtime/private-state.mjs +546 -0
  42. package/lib/runtime/project-context.mjs +2 -1
  43. package/lib/runtime/project-path.mjs +148 -12
  44. package/lib/runtime/project-trust.mjs +79 -0
  45. package/lib/runtime/projects.mjs +6 -3
  46. package/lib/runtime/state-migration.mjs +697 -0
  47. package/lib/runtime/state.mjs +22 -11
  48. package/lib/shared/automatic-result-key.mjs +6 -0
  49. package/lib/shared/json-file.mjs +55 -6
  50. package/lib/ui/help.mjs +6 -4
  51. package/lib/web/codex-app-server.mjs +26 -5
  52. package/lib/web/codex-sessions.mjs +5 -3
  53. package/lib/web/cookie-auth.mjs +1 -1
  54. package/lib/web/external-sessions.mjs +54 -5
  55. package/lib/web/http-routes.mjs +13 -9
  56. package/lib/web/http.mjs +20 -2
  57. package/lib/web/native-sessions.mjs +14 -7
  58. package/lib/web/peer-actions.mjs +27 -27
  59. package/lib/web/project-contexts.mjs +2 -3
  60. package/lib/web/runtime-main.mjs +22 -5
  61. package/lib/web/runtime.mjs +18 -4
  62. package/lib/web/session-serialize.mjs +1 -1
  63. package/lib/web/startup.mjs +115 -22
  64. package/lib/web/task-results.mjs +20 -17
  65. package/lib/web/terminal-assets.mjs +18 -0
  66. package/lib/web/tls.mjs +84 -33
  67. package/lib/web/tmux-sessions.mjs +2 -2
  68. package/lib/web/tmux-stream.mjs +3 -4
  69. package/lib/web/ui-codex.mjs +161 -32
  70. package/lib/web/ui-interactions.mjs +137 -4
  71. package/lib/web/ui-native.mjs +31 -13
  72. package/lib/web/ui-template.mjs +10 -3
  73. package/package.json +1 -1
  74. package/scripts/mcp-form-live-fixture.mjs +81 -0
  75. package/scripts/native-installed-acceptance.mjs +413 -0
  76. package/scripts/regression.mjs +46 -29
  77. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  78. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  79. package/scripts/web-native-interaction-acceptance.mjs +77 -10
@@ -3,10 +3,12 @@ import process from 'node:process';
3
3
  import { intOpt, positiveSafeIntOpt, required } from '../cli-args.mjs';
4
4
  import { CliError } from '../shared/errors.mjs';
5
5
  import {
6
+ findRequestedLock,
6
7
  lockLabel,
7
8
  lockScope,
8
9
  locksConflict,
9
- scopedLockResource
10
+ scopedLockResource,
11
+ upsertRequestedLock
10
12
  } from '../core/coordination/locks.mjs';
11
13
  import { normalizeStateResources } from '../ui/state-render.mjs';
12
14
  import {
@@ -103,23 +105,31 @@ export function createWebPeerActions(deps = {}) {
103
105
  function webPeerRegister(projectCtx, peer, input = {}) {
104
106
  webMutationPeer(peer, input);
105
107
  const db = connect(projectCtx);
106
- const row = {
107
- id: peer,
108
- kind: input.kind || 'other',
109
- role: input.role || 'peer',
110
- worktree: path.resolve(input.worktree || projectCtx.cwd),
111
- branch: input.branch || detectBranch(projectCtx.cwd),
112
- pid: intOpt(input, 'pid', process.ppid),
113
- status: input.status || 'idle',
114
- capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
115
- };
116
108
  try {
109
+ const existing = db.prepare('SELECT * FROM peers WHERE id = ?').get(peer) || null;
110
+ const explicitPid = Object.hasOwn(input, 'pid');
111
+ const row = {
112
+ id: peer,
113
+ kind: input.kind || existing?.kind || 'other',
114
+ role: input.role || existing?.role || 'peer',
115
+ worktree: path.resolve(input.worktree || existing?.worktree || projectCtx.cwd),
116
+ branch: input.branch || existing?.branch || detectBranch(projectCtx.cwd),
117
+ pid: explicitPid ? intOpt(input, 'pid') : (existing?.pid ?? null),
118
+ ...(!explicitPid ? { processIdentity: existing ? {
119
+ pid: existing.pid,
120
+ startToken: existing.pid_start_token,
121
+ commandHash: existing.pid_command_hash
122
+ } : null } : {}),
123
+ status: input.status || existing?.status || 'idle',
124
+ capabilities: Array.isArray(input.cap) ? input.cap.join(',') :
125
+ (input.cap || input.capabilities || existing?.capabilities || 'web')
126
+ };
117
127
  upsertPeer(db, row);
118
128
  addEvent(db, 'peer.registered', peer, null, webAuditPayload(peer, input, row));
129
+ return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
119
130
  } finally {
120
131
  db.close();
121
132
  }
122
- return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
123
133
  }
124
134
 
125
135
  function webPeerHeartbeat(projectCtx, peer, input = {}) {
@@ -294,21 +304,11 @@ export function createWebPeerActions(deps = {}) {
294
304
  }
295
305
  const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
296
306
  const expiresAt = leaseDeadline(t, ttl);
297
- db.prepare(`
298
- INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec)
299
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
300
- ON CONFLICT(resource) DO UPDATE SET
301
- base_resource = excluded.base_resource,
302
- scope = excluded.scope,
303
- owner = excluded.owner,
304
- task_id = excluded.task_id,
305
- reason = excluded.reason,
306
- expires_at = excluded.expires_at,
307
- created_at = excluded.created_at,
308
- ttl_sec = excluded.ttl_sec
309
- `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
307
+ const acquired = upsertRequestedLock(db, requested, {
308
+ owner: peer, task_id: taskId, reason, expires_at: expiresAt, created_at: t, ttl_sec: ttl
309
+ });
310
310
  addEvent(db, 'lock.acquired', peer, taskId, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null }));
311
- return { lock: db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource) };
311
+ return { lock: acquired };
312
312
  }
313
313
  });
314
314
  if (outcome.error) throw outcome.error;
@@ -327,7 +327,7 @@ export function createWebPeerActions(deps = {}) {
327
327
  try {
328
328
  touchPeer(db, peer, null);
329
329
  const result = tx(db, () => {
330
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
330
+ const existing = findRequestedLock(db, requested);
331
331
  if (!existing) return { released: false, ...requested };
332
332
  if (existing.owner !== peer) {
333
333
  throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
@@ -3,11 +3,10 @@
3
3
  // fail-closed path re-resolution, per-project DB connections, and
4
4
  // session/peer lookup helpers used by the HTTP and WS layers.
5
5
 
6
- import path from 'node:path';
7
6
  import { randomBytes } from 'node:crypto';
8
7
  import WebSocket from 'ws';
9
8
  import { CliError } from '../shared/errors.mjs';
10
- import { contextForProject } from '../runtime/paths.mjs';
9
+ import { contextForProject, projectDbPath } from '../runtime/paths.mjs';
11
10
  import { resolveProjectDatabase } from '../runtime/project-path.mjs';
12
11
  import {
13
12
  projectRecord,
@@ -101,7 +100,7 @@ export function createProjectContexts(deps) {
101
100
  ctx.root;
102
101
  const requestedDb = url.searchParams.get('db') ||
103
102
  req.headers['x-hcc-db'] ||
104
- path.join(requestedRoot, '.hello-cc', 'mesh.db');
103
+ projectDbPath(requestedRoot);
105
104
  return rememberProject(
106
105
  resolveWebProjectContext(requestedRoot, requestedDb),
107
106
  { activity: true }
@@ -97,7 +97,8 @@ import {
97
97
  requestOriginMatches,
98
98
  sendFile,
99
99
  sendJson,
100
- tokenMatches
100
+ tokenMatches,
101
+ tokenlessRequestAllowed
101
102
  } from '../web/http.mjs';
102
103
  import {
103
104
  API_VERSION,
@@ -433,9 +434,9 @@ const {
433
434
  listCodexThreads, readCodexThread, forkCodexThread } = createCodexSessions({
434
435
  sessions, sessionKey, nextProjectSessionId, connectWebProject, upsertPeer,
435
436
  upsertCanonicalPeerBinding, observePeerEvidence,
436
- broadcast: (session, payload) => {
437
+ broadcast: (session, payload, event) => {
437
438
  if (payload.type === 'codex_state') {
438
- try { observeCodexResults(session, payload.state); }
439
+ try { observeCodexResults(session, payload.state, event); }
439
440
  catch (error) { console.error(redactedLogText(`Result evidence could not be saved: ${error.code || 'UNKNOWN'}`)); }
440
441
  }
441
442
  broadcast(session, payload);
@@ -488,14 +489,30 @@ const {
488
489
  ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
489
490
  });
490
491
 
492
+ const dispatchHttp = (req, res) => {
493
+ void handleWebRequest(req, res).catch((error) => {
494
+ try {
495
+ console.error(redactedLogText(`HTTP request failed: ${error.code || 'UNKNOWN'}`));
496
+ if (!res.headersSent) sendJson(res, 500, { ok: false, error: { code: 'SERVER_ERROR', message: 'internal server error' } });
497
+ else res.destroy();
498
+ } catch {
499
+ try { res.destroy(); } catch {}
500
+ }
501
+ });
502
+ };
491
503
  const server = useTls
492
- ? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, handleWebRequest)
493
- : http.createServer(handleWebRequest);
504
+ ? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, dispatchHttp)
505
+ : http.createServer(dispatchHttp);
494
506
 
495
507
  const wss = new WebSocketServer({ noServer: true });
496
508
  server.on('upgrade', (req, socket, head) => {
497
509
  try {
498
510
  const url = requestUrl(req);
511
+ if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
512
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
513
+ socket.destroy();
514
+ return;
515
+ }
499
516
  if (!readWebSocketApiVersion(url).ok) {
500
517
  const body = JSON.stringify(apiVersionUnsupportedBody());
501
518
  socket.end([
@@ -14,8 +14,12 @@ export function runtimeConnectHost(host) {
14
14
  return host;
15
15
  }
16
16
 
17
+ function urlHost(host) {
18
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
19
+ }
20
+
17
21
  export function runtimeBaseUrl(host, port, tls = false) {
18
- return `${tls ? 'https' : 'http'}://${runtimeConnectHost(host)}:${port}`;
22
+ return `${tls ? 'https' : 'http'}://${urlHost(runtimeConnectHost(host))}:${port}`;
19
23
  }
20
24
 
21
25
  export function runtimeApiUrl(runtime, route) {
@@ -24,7 +28,17 @@ export function runtimeApiUrl(runtime, route) {
24
28
  }
25
29
 
26
30
  export function requestUrl(req) {
27
- return new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
31
+ try {
32
+ const base = new URL(`http://${req.headers.host || 'localhost'}`);
33
+ if (base.username || base.password || base.pathname !== '/' || base.search || base.hash) {
34
+ throw new Error('Invalid Host authority');
35
+ }
36
+ const url = new URL(req.url || '/', base);
37
+ if (url.origin !== base.origin) throw new Error('Request target changes authority');
38
+ return url;
39
+ } catch {
40
+ throw new CliError('BAD_REQUEST', 'Invalid request URL or Host header');
41
+ }
28
42
  }
29
43
 
30
44
  export function isLoopbackHost(host) {
@@ -84,13 +98,13 @@ export function publicRuntimeUrl(runtime, projectRoot = null) {
84
98
  ? '<machine-ip>'
85
99
  : runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
86
100
  const scheme = runtime.tls ? 'https' : 'http';
87
- return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
101
+ return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
88
102
  }
89
103
 
90
104
  export function localRuntimeUrl(runtime, projectRoot = null) {
91
105
  const host = runtimeConnectHost(runtime.host || '127.0.0.1');
92
106
  const scheme = runtime.tls ? 'https' : 'http';
93
- return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
107
+ return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
94
108
  }
95
109
 
96
110
  // Issue a request to a runtime API route. Returns { ok, status, text }.
@@ -105,7 +105,7 @@ export function createSessionSerialize({
105
105
  : session.type === 'native' ? ['chat', 'delivery-receipts', 'results',
106
106
  ...(session.nativeState?.capabilities?.interrupt ? ['turn-control'] : [])] : ['terminal', 'results'],
107
107
  local_clients: session.type === 'tmux' ? localClientObservation(session) : null,
108
- executor_id: session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
108
+ executor_id: session.executorId || session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
109
109
  native_connected: session.type === 'native' ? Boolean(session.nativeState?.connected) : null,
110
110
  created_at: session.createdAt,
111
111
  exited_at: session.exitedAt || null,
@@ -5,6 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import process from 'node:process';
8
+ import { createHash, randomUUID } from 'node:crypto';
8
9
  import { URL } from 'node:url';
9
10
  import { spawn, spawnSync } from 'node:child_process';
10
11
  import { setTimeout as sleep } from 'node:timers/promises';
@@ -13,10 +14,15 @@ import { redactCliArgs } from '../shared/redact.mjs';
13
14
  import { intOpt, parseOpts, splitGlobalArgs, validateOpts } from '../cli-args.mjs';
14
15
  import { printResult } from '../format.mjs';
15
16
  import { commandPath, tailFile } from '../cli-runtime.mjs';
17
+ import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
16
18
  import { registerProject } from '../runtime/projects.mjs';
17
- import { runtimePath, globalRuntimePath, webLogPath } from '../runtime/paths.mjs';
19
+ import { runtimePath, globalRuntimePath, globalStateDir, webLogPath } from '../runtime/paths.mjs';
20
+ import { secureExistingProjectStateDirectory } from '../runtime/project-path.mjs';
21
+ import { openPrivateAppendFile } from '../runtime/private-file.mjs';
18
22
  import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
19
23
  import { runtimeRequest } from '../runtime/client.mjs';
24
+ import { withFileLock } from '../shared/file-lock.mjs';
25
+ import { writeJsonSafe } from '../shared/json-file.mjs';
20
26
  import {
21
27
  expectedWebHost,
22
28
  isLoopbackHost,
@@ -32,6 +38,56 @@ import { API_VERSION } from '../web/api-version.mjs';
32
38
  import { ensureTmuxAvailable } from '../tmux.mjs';
33
39
 
34
40
  export const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
41
+ const WEB_START_LOCK_WAIT_MS = WEB_RUNTIME_START_TIMEOUT_MS + 30_000;
42
+
43
+ function webStartOwnerAlive(owner) {
44
+ const pid = owner?.pid;
45
+ if (!Number.isInteger(pid) || pid <= 0) return true;
46
+ const observed = inspectProcessIdentity(pid);
47
+ if (observed.state === 'dead') return false;
48
+ if (owner.process_identity && observed.state === 'live') {
49
+ return compareProcessIdentity(owner.process_identity, observed.identity) !== 'dead';
50
+ }
51
+ return true;
52
+ }
53
+
54
+ async function withWebStartLock(fn) {
55
+ const lockFile = path.join(globalStateDir(), 'web-start.lock');
56
+ const owner = {
57
+ pid: process.pid,
58
+ process_identity: inspectProcessIdentity(process.pid).identity,
59
+ nonce: randomUUID()
60
+ };
61
+ const deadline = performance.now() + WEB_START_LOCK_WAIT_MS;
62
+ while (true) {
63
+ const acquired = withFileLock(lockFile, (lockedFile) => {
64
+ if (fs.existsSync(lockedFile)) {
65
+ let previous = null;
66
+ try { previous = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
67
+ if (!previous || webStartOwnerAlive(previous)) return false;
68
+ }
69
+ writeJsonSafe(lockedFile, owner, { mode: 0o600 });
70
+ return true;
71
+ });
72
+ if (acquired) break;
73
+ if (performance.now() >= deadline) {
74
+ throw new CliError('WEB_START_LOCK_TIMEOUT',
75
+ 'Timed out waiting for another web startup to finish.');
76
+ }
77
+ await sleep(100);
78
+ }
79
+ try {
80
+ return await fn();
81
+ } finally {
82
+ withFileLock(lockFile, (lockedFile) => {
83
+ let current = null;
84
+ try { current = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
85
+ if (current?.nonce === owner.nonce && current.pid === owner.pid) {
86
+ fs.rmSync(lockedFile, { force: true });
87
+ }
88
+ });
89
+ }
90
+ }
35
91
 
36
92
  export function createWebStartup(deps) {
37
93
  const {
@@ -84,25 +140,41 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
84
140
  const ppid = Number.parseInt(match[2], 10);
85
141
  if (!Number.isFinite(pid)) continue;
86
142
  if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
87
- rows.push({ pid, args: match[3] });
143
+ rows.push({ pid, ppid, args: match[3] });
88
144
  }
89
145
 
90
146
  const ancestorPids = currentProcessAncestorPids(ppidByPid);
91
- const pids = [];
147
+ const candidates = [];
92
148
  for (const row of rows) {
149
+ // A live parent still owns its web command. In particular, another CLI
150
+ // invocation waiting for the startup lock is not an orphan runtime.
151
+ if (row.ppid !== 1) continue;
93
152
  if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
94
- if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
153
+ if (!hccWebProcessMatches(row.args, ctx)) continue;
154
+ const observed = inspectProcessIdentity(row.pid);
155
+ if (observed.state === 'live' &&
156
+ observed.identity.commandHash === createHash('sha256').update(row.args.trim()).digest('hex')) {
157
+ candidates.push(observed.identity);
158
+ }
95
159
  }
96
- if (!pids.length) return;
160
+ if (!candidates.length) return;
161
+
162
+ const stillSameProcess = (identity) => {
163
+ const observed = inspectProcessIdentity(identity.pid);
164
+ return observed.state === 'live' &&
165
+ compareProcessIdentity(identity, observed.identity) === 'live' &&
166
+ identity.commandHash === observed.identity.commandHash;
167
+ };
97
168
 
98
- for (const pid of pids) {
99
- try { process.kill(pid, 'SIGTERM'); } catch {}
169
+ for (const identity of candidates) {
170
+ if (!stillSameProcess(identity)) continue;
171
+ try { process.kill(identity.pid, 'SIGTERM'); } catch {}
100
172
  }
101
173
  await sleep(250);
102
- for (const pid of pids) {
174
+ for (const identity of candidates) {
175
+ if (!stillSameProcess(identity)) continue;
103
176
  try {
104
- process.kill(pid, 0);
105
- process.kill(pid, 'SIGKILL');
177
+ process.kill(identity.pid, 'SIGKILL');
106
178
  } catch {}
107
179
  }
108
180
  }
@@ -160,10 +232,15 @@ async function startWebBackground(ctx, args) {
160
232
  assertWebTokenForHost(requestedHost, !opts['no-token']);
161
233
  if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
162
234
  ensureTmuxAvailable({ autoInstall: true });
235
+ return withWebStartLock(() => startWebBackgroundLocked(ctx, args, opts));
236
+ }
237
+
238
+ async function startWebBackgroundLocked(ctx, args, opts) {
163
239
  const setup = await prepareLocalBus(ctx, {
164
240
  ...opts,
165
241
  installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
166
242
  });
243
+ secureExistingProjectStateDirectory(ctx.root);
167
244
  registerProject(ctx);
168
245
 
169
246
  const existing = await readHealthyGlobalRuntime();
@@ -201,26 +278,42 @@ async function startWebBackground(ctx, args) {
201
278
  try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
202
279
  await sleep(250);
203
280
  }
281
+ secureExistingProjectStateDirectory(ctx.root);
204
282
  await stopOrphanWebRuntimes(ctx);
205
283
 
284
+ // A project path can be reused for a different directory after setup. Do
285
+ // not remove its old private runtime pointer or append to its old Web log.
286
+ secureExistingProjectStateDirectory(ctx.root);
206
287
  try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
207
288
  try { fs.rmSync(globalRuntimePath(), { force: true }); } catch {}
208
289
 
209
290
  const logFile = webLogPath(ctx);
210
- fs.mkdirSync(path.dirname(logFile), { recursive: true });
211
- // bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
291
+ let logFd = openPrivateAppendFile(logFile);
212
292
  try {
213
- if (fs.statSync(logFile).size > 5 * 1024 * 1024) {
214
- try { fs.rmSync(`${logFile}.1`, { force: true }); } catch {}
215
- try { fs.renameSync(logFile, `${logFile}.1`); } catch {}
293
+ // bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
294
+ if (fs.fstatSync(logFd).size > 5 * 1024 * 1024) {
295
+ const previous = `${logFile}.1`;
296
+ try {
297
+ const stat = fs.lstatSync(previous);
298
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
299
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
300
+ throw new CliError('PROJECT_PATH_FORBIDDEN', `Rotated Web log is not owned: ${previous}`);
301
+ }
302
+ fs.unlinkSync(previous);
303
+ } catch (error) {
304
+ if (error?.code !== 'ENOENT') throw error;
305
+ }
306
+ fs.closeSync(logFd);
307
+ logFd = null;
308
+ fs.renameSync(logFile, previous);
309
+ logFd = openPrivateAppendFile(logFile);
216
310
  }
217
- } catch {}
218
- const redactedStart = redactedLogText(`${CLI_NAME} web ${redactCliArgs(args).join(' ')}`);
219
- fs.appendFileSync(logFile, `\n[${new Date().toISOString()}] ${redactedStart}\n`, { mode: 0o600 });
220
- // The runtime echoes token-bearing URLs into web.log; keep it owner-only so a
221
- // co-tenant on the machine cannot read the token (net-02).
222
- try { fs.chmodSync(logFile, 0o600); } catch {}
223
- const logFd = fs.openSync(logFile, 'a');
311
+ const redactedStart = redactedLogText(`${CLI_NAME} web ${redactCliArgs(args).join(' ')}`);
312
+ fs.writeSync(logFd, `\n[${new Date().toISOString()}] ${redactedStart}\n`);
313
+ } catch (error) {
314
+ if (logFd !== null) fs.closeSync(logFd);
315
+ throw error;
316
+ }
224
317
 
225
318
  const childArgs = [
226
319
  commandPath(),
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { selectCurrentTask } from '../core/coordination/automation.mjs';
3
2
  import { listTaskResults, recordTaskResult, summarizeTaskResults } from '../core/coordination/results.mjs';
4
3
  import { CliError } from '../shared/errors.mjs';
@@ -30,8 +29,8 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
30
29
  return { result };
31
30
  } finally { db.close(); }
32
31
  }
33
- function observeCodexResults(session, state) {
34
- if (!session.binding?.provider_session_id || state.executorId !== session.adapter?.snapshot().executorId) return;
32
+ function observeCodexResults(session, state, event) {
33
+ if (!session.binding?.provider_session_id || state.executorId !== (session.executorId || session.adapter?.snapshot().executorId)) return;
35
34
  const thread = state.threads?.find(t => t.id === session.binding.provider_session_id);
36
35
  if (!thread?.turns?.length) return;
37
36
  const db = connectWebProject(session.ctx);
@@ -39,9 +38,17 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
39
38
  const peer = resolveSessionPeerId(db, session);
40
39
  let scopes = turnScopes.get(session);
41
40
  if (!scopes) { scopes = new Map(); turnScopes.set(session, scopes); }
42
- for (const event of state.events || []) {
43
- if (event.method === 'turn/started' && event.params?.threadId === thread.id && event.params.turn?.id && !scopes.has(event.params.turn.id)) {
44
- scopes.set(event.params.turn.id, currentTask(db, peer)?.id || null);
41
+ // The snapshot retains old events. Only the adapter's fresh event may bind
42
+ // a turn to the task that is current at this instant.
43
+ if (event?.method === 'turn/started' && event.params?.threadId === thread.id &&
44
+ event.params.turn?.id && !scopes.has(event.params.turn.id)) {
45
+ scopes.set(event.params.turn.id, currentTask(db, peer)?.id || null);
46
+ }
47
+ function recordAutomatic(input) {
48
+ try { recordTaskResult(db, input, { now, addEvent, automatic: true }); return true; }
49
+ catch (error) {
50
+ if (error instanceof CliError && ['TASK_OWNER_MISMATCH', 'NOT_FOUND'].includes(error.code)) return false;
51
+ throw error;
45
52
  }
46
53
  }
47
54
  for (const turn of thread.turns) {
@@ -49,27 +56,23 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
49
56
  if (!taskId) continue;
50
57
  const common = { peer, taskId, executorId: state.executorId, threadId: thread.id,
51
58
  turnId: turn.id, source: 'executor', stage: 'local' };
59
+ let taskUnavailable = false;
52
60
  for (const item of turn.items || []) {
53
61
  if (item.type !== 'commandExecution' || !['completed', 'failed', 'declined'].includes(item.status)) continue;
54
62
  const exitCode = item.exitCode;
55
63
  const status = exitCode === 0 && item.status === 'completed' ? 'passed'
56
64
  : item.status === 'failed' || item.status === 'declined' || (Number.isSafeInteger(exitCode) && exitCode !== 0) ? 'failed' : 'pending';
57
- recordTaskResult(db, { ...common, itemId: item.id, kind: 'command', status, exitCode,
58
- resultKey: `${state.executorId}:${turn.id}:${item.id}:command`,
59
- title: String(item.command || 'Command execution').slice(0, 300),
60
- command: String(item.command || '').slice(0, 64000),
61
- details: String(item.aggregatedOutput || '').slice(0, 64000), evidence: [] }, { now, addEvent });
65
+ if (!recordAutomatic({ ...common, itemId: item.id, kind: 'command', status, exitCode })) {
66
+ taskUnavailable = true;
67
+ break;
68
+ }
62
69
  }
70
+ if (taskUnavailable) continue;
63
71
  if (turn.diff && ['completed', 'failed', 'interrupted'].includes(turn.status)) {
64
- const diff = String(turn.diff).slice(0, 64000);
65
- recordTaskResult(db, { ...common, kind: 'diff', status: 'pending', title: 'Turn changes awaiting review', diff,
66
- resultKey: `${state.executorId}:${turn.id}:diff:${createHash('sha256').update(diff).digest('hex')}` }, { now, addEvent });
72
+ recordAutomatic({ ...common, kind: 'diff', status: 'pending' });
67
73
  }
68
74
  }
69
75
  while (scopes.size > 100) scopes.delete(scopes.keys().next().value);
70
- } catch (error) {
71
- // A delayed executor result must not attach to a task after ownership changed.
72
- if (!(error instanceof CliError) || !['TASK_OWNER_MISMATCH', 'NOT_FOUND'].includes(error.code)) throw error;
73
76
  } finally { db.close(); }
74
77
  }
75
78
  return { readSessionResults, writeSessionResult, observeCodexResults };
@@ -0,0 +1,18 @@
1
+ import path from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const packages = Object.freeze({
6
+ 'xterm.js': '@xterm/xterm',
7
+ 'xterm.css': '@xterm/xterm',
8
+ 'addon-fit.js': '@xterm/addon-fit',
9
+ 'addon-search.js': '@xterm/addon-search'
10
+ });
11
+
12
+ // npm may place dependencies above the HCC package directory. Resolve from
13
+ // this module's installation rather than assuming a nested node_modules.
14
+ export function terminalAssetPath(name) {
15
+ if (!Object.hasOwn(packages, name)) throw new TypeError('Unknown terminal asset');
16
+ const entry = require.resolve(packages[name]);
17
+ return name === 'xterm.css' ? path.resolve(path.dirname(entry), '..', 'css', name) : entry;
18
+ }