@logicseek/hello-cc 1.0.2 → 1.0.4

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 (40) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/docs/dsh.md +3 -3
  3. package/docs/dsh.zh-CN.md +3 -3
  4. package/lib/cli/app.mjs +1 -1
  5. package/lib/cli/commands/hook.mjs +3 -1
  6. package/lib/cli/commands/lifecycle.mjs +14 -3
  7. package/lib/cli/commands/query.mjs +15 -10
  8. package/lib/cli/commands/run.mjs +7 -6
  9. package/lib/db/connection.mjs +82 -9
  10. package/lib/discover.mjs +4 -4
  11. package/lib/integrations/hooks.mjs +132 -40
  12. package/lib/integrations/peers/identity.mjs +42 -7
  13. package/lib/process/identity.mjs +36 -15
  14. package/lib/runtime/client.mjs +9 -1
  15. package/lib/runtime/native/service.mjs +107 -24
  16. package/lib/runtime/native/store.mjs +63 -6
  17. package/lib/runtime/project-path.mjs +90 -7
  18. package/lib/shared/file-lock.mjs +62 -17
  19. package/lib/shared/socket-lock-worker.mjs +15 -6
  20. package/lib/web/codex-app-server.mjs +19 -1
  21. package/lib/web/codex-sessions.mjs +2 -0
  22. package/lib/web/cookie-auth.mjs +1 -1
  23. package/lib/web/http-routes.mjs +11 -7
  24. package/lib/web/http.mjs +20 -2
  25. package/lib/web/native-sessions.mjs +13 -6
  26. package/lib/web/peer-actions.mjs +27 -27
  27. package/lib/web/runtime-main.mjs +24 -7
  28. package/lib/web/runtime.mjs +18 -4
  29. package/lib/web/session-serialize.mjs +1 -1
  30. package/lib/web/startup.mjs +84 -10
  31. package/lib/web/task-results.mjs +8 -5
  32. package/lib/web/terminal-assets.mjs +18 -0
  33. package/lib/web/tmux-client-snapshot.mjs +85 -0
  34. package/lib/web/tmux-stream.mjs +3 -4
  35. package/lib/web/ui-codex.mjs +137 -20
  36. package/lib/web/ui-interactions.mjs +37 -3
  37. package/lib/web/ui-native.mjs +21 -9
  38. package/lib/web/ui-template.mjs +8 -2
  39. package/package.json +1 -1
  40. package/scripts/regression.mjs +212 -47
@@ -139,14 +139,14 @@ export function runSocketLockWorker(workerData) {
139
139
  let received = '';
140
140
  let settled = false;
141
141
  let socket;
142
- const settle = (matching) => {
142
+ const settle = (matching, legacy = false) => {
143
143
  if (settled) return;
144
144
  settled = true;
145
145
  clearTimeout(timer);
146
146
  try { socket?.destroy(); } catch {}
147
- callback(matching);
147
+ callback(matching, legacy);
148
148
  };
149
- const timer = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
149
+ const timer = setTimeout(() => settle(false, received.length === 0), PROBE_TIMEOUT_MS);
150
150
  try {
151
151
  socket = net.createConnection({ host: '127.0.0.1', port });
152
152
  socket.setEncoding('utf8');
@@ -156,7 +156,10 @@ export function runSocketLockWorker(workerData) {
156
156
  settle(received === expected);
157
157
  }
158
158
  });
159
- socket.once('end', () => settle(received === expected));
159
+ // The previous native runtime owner accepted and immediately ended the
160
+ // connection with no banner. Native leases must not treat it as an
161
+ // unrelated service and start a second runtime on another candidate.
162
+ socket.once('end', () => settle(received === expected, received.length === 0));
160
163
  socket.once('error', () => settle(false));
161
164
  } catch {
162
165
  settle(false);
@@ -184,9 +187,15 @@ export function runSocketLockWorker(workerData) {
184
187
  requestTerminalFailure();
185
188
  return;
186
189
  }
187
- probeOccupant(port, target.identity, (matching) => {
190
+ probeOccupant(port, target.identity, (matching, legacy) => {
188
191
  if (finished || attemptGeneration !== generation) return;
189
- if (matching) retryCleanly(attemptGeneration);
192
+ if (legacy && workerData.rejectLegacyOwner) {
193
+ generation += 1;
194
+ closeServers((error) => {
195
+ if (error) requestTerminalFailure();
196
+ else finish(states.BUSY, 2);
197
+ });
198
+ } else if (matching) retryCleanly(attemptGeneration);
190
199
  else bindCandidate(targetIndex, candidateIndex + 1, boundForTarget, attemptGeneration);
191
200
  });
192
201
  };
@@ -10,6 +10,7 @@ const APPROVAL_METHODS = CODEX_INTERACTION_METHODS;
10
10
  const TERMINAL_TURNS = new Set(['completed', 'interrupted', 'failed']);
11
11
  const MAX_LINE_BYTES = 8 * 1024 * 1024;
12
12
  const MAX_TEXT = 65536;
13
+ const STREAM_UPDATE_MS = 50;
13
14
 
14
15
  export class CodexAppServerError extends Error {
15
16
  constructor(code, message, extra = {}) {
@@ -70,6 +71,8 @@ export function createCodexAppServer({
70
71
  let buffer = '';
71
72
  let truncated = false;
72
73
  let closePromise = null;
74
+ let streamTimer = null;
75
+ let streamEvent = null;
73
76
  const threads = new Map();
74
77
  const pending = new Map();
75
78
  const approvals = new Map();
@@ -111,11 +114,26 @@ export function createCodexAppServer({
111
114
  });
112
115
  }
113
116
 
117
+ function publishChange(event) {
118
+ clearTimeout(streamTimer); streamTimer = null; streamEvent = null;
119
+ try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
120
+ }
121
+
114
122
  function changed(method, params = {}) {
115
123
  const event = { method, params: bounded(params, 8192), at: Date.now() };
116
124
  events.push(event);
117
125
  if (events.length > 100) events.shift();
118
- try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
126
+ // Keep protocol state current on every token, but copy/broadcast its complete
127
+ // retained history at most once per interval during a text-only burst.
128
+ // Approval, completion, error and connection changes publish immediately;
129
+ // their snapshot includes all pending text and cancels the trailing timer.
130
+ if (method === 'item/agentMessage/delta') {
131
+ streamEvent = event;
132
+ if (!streamTimer) {
133
+ streamTimer = setTimeout(() => publishChange(streamEvent), STREAM_UPDATE_MS);
134
+ streamTimer.unref?.();
135
+ }
136
+ } else publishChange(event);
119
137
  }
120
138
 
121
139
  function ensureThread(id) {
@@ -81,6 +81,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
81
81
  binding: { transport: 'app-server', runtimeSessionId: id } });
82
82
  return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
83
83
  }, onChange(state) {
84
+ session.executorId ||= state.executorId;
84
85
  broadcast(session, { type: 'codex_state', state });
85
86
  if (state.status === 'disconnected' && !closeScheduled) {
86
87
  closeScheduled = true;
@@ -114,6 +115,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
114
115
  throw new CliError('CODEX_DISCONNECTED', 'Executor disconnected while opening its thread; no session was registered');
115
116
  }
116
117
  session.pid = executorState.pid || null;
118
+ session.executorId = executorState.executorId;
117
119
  const save = connectWebProject(pctx);
118
120
  try {
119
121
  tx(save, () => {
@@ -99,7 +99,7 @@ export function createCookieAuth({
99
99
  }
100
100
 
101
101
  function webAuthMode(url, req) {
102
- if (authOk(url, req, token)) return 'token';
102
+ if (authOk(url, req, token, { trustProxy, proxyOrigin })) return 'token';
103
103
  return cookieSessionOk(req) ? 'cookie' : null;
104
104
  }
105
105
 
@@ -7,12 +7,12 @@ import process from 'node:process';
7
7
  import { URL } from 'node:url';
8
8
  import { publicCliFailure, CliError } from '../shared/errors.mjs';
9
9
  import { required } from '../cli-args.mjs';
10
- import { packageRoot } from '../cli-runtime.mjs';
10
+ import { terminalAssetPath } from './terminal-assets.mjs';
11
11
  import { projectRecord } from '../runtime/projects.mjs';
12
12
  import { writeRuntime } from '../runtime/state.mjs';
13
13
  import { normalizeStateResources } from '../ui/state-render.mjs';
14
14
  import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
15
- import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
15
+ import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches, tokenlessRequestAllowed } from '../web/http.mjs';
16
16
  import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
17
17
  import { isDshHookBinding } from '../core/peers/bindings.mjs';
18
18
  import { writeAdoptionState } from './adoption-state.mjs';
@@ -48,8 +48,12 @@ export function createHttpRoutes(deps) {
48
48
  }
49
49
 
50
50
  const handleWebRequest = async (req, res) => {
51
- const url = requestUrl(req);
52
51
  try {
52
+ const url = requestUrl(req);
53
+ if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
54
+ sendJson(res, 403, { ok: false, error: { code: 'UNTRUSTED_HOST', message: 'Tokenless access requires a trusted local Host' } });
55
+ return;
56
+ }
53
57
  if (req.method === 'GET' && url.pathname === '/') {
54
58
  const accept = req.headers.accept || '';
55
59
  const isBrowserNav = req.headers['sec-fetch-mode'] === 'navigate' || accept.includes('text/html');
@@ -85,19 +89,19 @@ export function createHttpRoutes(deps) {
85
89
  return;
86
90
  }
87
91
  if (req.method === 'GET' && url.pathname === '/assets/xterm.js') {
88
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js'), 'application/javascript; charset=utf-8');
92
+ sendFile(res, terminalAssetPath('xterm.js'), 'application/javascript; charset=utf-8');
89
93
  return;
90
94
  }
91
95
  if (req.method === 'GET' && url.pathname === '/assets/xterm.css') {
92
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css'), 'text/css; charset=utf-8');
96
+ sendFile(res, terminalAssetPath('xterm.css'), 'text/css; charset=utf-8');
93
97
  return;
94
98
  }
95
99
  if (req.method === 'GET' && url.pathname === '/assets/addon-fit.js') {
96
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-fit', 'lib', 'addon-fit.js'), 'application/javascript; charset=utf-8');
100
+ sendFile(res, terminalAssetPath('addon-fit.js'), 'application/javascript; charset=utf-8');
97
101
  return;
98
102
  }
99
103
  if (req.method === 'GET' && url.pathname === '/assets/addon-search.js') {
100
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-search', 'lib', 'addon-search.js'), 'application/javascript; charset=utf-8');
104
+ sendFile(res, terminalAssetPath('addon-search.js'), 'application/javascript; charset=utf-8');
101
105
  return;
102
106
  }
103
107
  if (req.method === 'POST' && url.pathname === '/login') {
package/lib/web/http.mjs CHANGED
@@ -84,6 +84,24 @@ export function isLoopbackRemote(req) {
84
84
  return addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.');
85
85
  }
86
86
 
87
+ function localHostAuthority(req) {
88
+ const host = req?.headers?.host;
89
+ if (typeof host !== 'string') return false;
90
+ const match = /^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/i.exec(host);
91
+ if (!match) return false;
92
+ return !match[1] || (Number(match[1]) > 0 && Number(match[1]) <= 65535);
93
+ }
94
+
95
+ export function tokenlessRequestAllowed(req, options = {}) {
96
+ if (!isLoopbackRemote(req)) return false;
97
+ if (localHostAuthority(req)) return true;
98
+ if (!options.trustProxy || !requestMatchesProxyOrigin(req, options)) return false;
99
+ const expected = normalizedProxyOrigin(options.proxyOrigin);
100
+ const actual = normalizedOriginAuthority(req?.headers?.host, expected?.protocol);
101
+ return Boolean(expected && actual && actual.hostname === expected.authority.hostname &&
102
+ actual.port === expected.authority.port);
103
+ }
104
+
87
105
  export function tokenMatches(provided, expected) {
88
106
  if (!provided || !expected || provided.length !== expected.length) return false;
89
107
  try {
@@ -174,10 +192,10 @@ export function requestOriginMatches(req, options = {}) {
174
192
  }
175
193
  }
176
194
 
177
- export function authOk(url, req, token) {
195
+ export function authOk(url, req, token, options = {}) {
178
196
  // No token configured: only trust loopback clients. A tokenless runtime is
179
197
  // refused on non-loopback binds at startup, so this is defense-in-depth.
180
- if (!token) return isLoopbackRemote(req);
198
+ if (!token) return tokenlessRequestAllowed(req, options);
181
199
  // v1-token-query-csrf-bypass: the query token is accepted ONLY on the cookie
182
200
  // exchange paths (the console page and the login endpoint). Everywhere else
183
201
  // — including all API routes and the WS upgrade — a leaked ?token= URL must
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
3
4
  import { CliError } from '../shared/errors.mjs';
4
5
  import { redactSecrets } from '../shared/redact.mjs';
5
6
  import { tx } from '../db/schema.mjs';
@@ -29,10 +30,17 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
29
30
  return redactSecrets({ code: error.code || 'NATIVE_RUNTIME_OFFLINE',
30
31
  message: error.message || 'Native runtime is disconnected' });
31
32
  }
33
+ function publishState(session, state) {
34
+ // Event cursors do not cover approvals, delivery receipts or capabilities.
35
+ // Compare the complete normalized state; validation and lease revocation
36
+ // still run on every read, including reads that need no new UI frame.
37
+ const changed = !isDeepStrictEqual(session.nativeState, state);
38
+ session.nativeState = state;
39
+ if (changed) broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
40
+ }
32
41
  function disconnect(session, error, retire = false) {
33
42
  session.status = 'disconnected';
34
- session.nativeState = { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) };
35
- broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
43
+ publishState(session, { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) });
36
44
  if (retire && !session.nativeRetired) {
37
45
  session.nativeRetired = true;
38
46
  closeSessionClients(session);
@@ -100,7 +108,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
100
108
  const turnId = value.snapshot.turnId || value.snapshot.activeTurnId || value.active_delivery?.turn_id || null;
101
109
  session.binding = binding;
102
110
  session.cwd = value.cwd;
103
- session.nativeState = redactSecrets({ backend: 'native', connected: true, root: value.root,
111
+ const state = redactSecrets({ backend: 'native', connected: true, root: value.root,
104
112
  peer: value.peer, provider: value.provider, generation: value.generation, owner: value.owner,
105
113
  executorId: value.owner, sessionId: value.snapshot.sessionId || null, turnId,
106
114
  status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {}, pendingApprovals: value.snapshot.pendingApprovals || [],
@@ -111,7 +119,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
111
119
  session.status = CLOSED.has(value.snapshot.status) ? 'exited'
112
120
  : value.snapshot.status === 'disconnected' ? 'disconnected' : 'running';
113
121
  if (session.status === 'exited') session.exitedAt = now();
114
- broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
122
+ publishState(session, state);
115
123
  return session.nativeSnapshot();
116
124
  }
117
125
  function makeSession(ctx, checked) {
@@ -261,8 +269,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
261
269
  if (action === 'respond') { await readState(session); current(session); }
262
270
  if (action === 'close') {
263
271
  session.status = 'exited'; session.exitedAt = now();
264
- session.nativeState = { ...session.nativeState, connected: false, status: 'closed', closing: false };
265
- broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
272
+ publishState(session, { ...session.nativeState, connected: false, status: 'closed', closing: false });
266
273
  closeSessionClients(session);
267
274
  session.actionTokens.clear();
268
275
  }
@@ -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 });
@@ -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,
@@ -122,6 +123,7 @@ import { createAutoAttach } from '../web/auto-attach.mjs';
122
123
  import { createPtySessions } from '../web/pty-sessions.mjs';
123
124
  import { createHttpRoutes } from '../web/http-routes.mjs';
124
125
  import { createSessionSerialize } from '../web/session-serialize.mjs';
126
+ import { createTmuxClientSnapshot } from '../web/tmux-client-snapshot.mjs';
125
127
  import { createTmuxStream } from '../web/tmux-stream.mjs';
126
128
  import {
127
129
  ensureTmuxAvailable,
@@ -230,6 +232,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
230
232
  const { WebSocketServer } = await import('ws');
231
233
  const pty = ptyModule.default || ptyModule;
232
234
  const sessions = new Map();
235
+ const tmuxClientSnapshot = createTmuxClientSnapshot();
233
236
 
234
237
  const {
235
238
  sessionKey, sessionsForProject,
@@ -237,10 +240,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
237
240
  serializeBindingSummary, serializeSession,
238
241
  broadcast, hasOpenClients, closeSessionClients: closeSerializedSessionClients
239
242
  } = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath,
240
- localClientObservation: (session) => {
241
- try { return tmuxClientObservation(tmuxSessionNameForPane(session.pane)); }
242
- catch { return { state: 'unknown', count: null }; }
243
- }
243
+ localClientObservation: tmuxClientSnapshot.observe
244
244
  });
245
245
  const controlLease = createControlLease({ broadcast: (session) => {
246
246
  for (const [actionToken, socket] of session.actionTokenSockets || []) {
@@ -488,14 +488,30 @@ const {
488
488
  ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
489
489
  });
490
490
 
491
+ const dispatchHttp = (req, res) => {
492
+ void handleWebRequest(req, res).catch((error) => {
493
+ try {
494
+ console.error(redactedLogText(`HTTP request failed: ${error.code || 'UNKNOWN'}`));
495
+ if (!res.headersSent) sendJson(res, 500, { ok: false, error: { code: 'SERVER_ERROR', message: 'internal server error' } });
496
+ else res.destroy();
497
+ } catch {
498
+ try { res.destroy(); } catch {}
499
+ }
500
+ });
501
+ };
491
502
  const server = useTls
492
- ? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, handleWebRequest)
493
- : http.createServer(handleWebRequest);
503
+ ? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, dispatchHttp)
504
+ : http.createServer(dispatchHttp);
494
505
 
495
506
  const wss = new WebSocketServer({ noServer: true });
496
507
  server.on('upgrade', (req, socket, head) => {
497
508
  try {
498
509
  const url = requestUrl(req);
510
+ if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
511
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
512
+ socket.destroy();
513
+ return;
514
+ }
499
515
  if (!readWebSocketApiVersion(url).ok) {
500
516
  const body = JSON.stringify(apiVersionUnsupportedBody());
501
517
  socket.end([
@@ -621,6 +637,7 @@ const {
621
637
  let cleanupPromise = null;
622
638
  let shutdownExitCode = 0;
623
639
  async function performRuntimeCleanup() {
640
+ tmuxClientSnapshot.close();
624
641
  clearRuntime(ctx);
625
642
  // rob-07: clear this runtime's per-project pointers for every registered
626
643
  // project, not just the primary ctx, so sibling projects don't keep
@@ -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,13 @@ 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';
18
20
  import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
19
21
  import { runtimeRequest } from '../runtime/client.mjs';
22
+ import { withFileLock } from '../shared/file-lock.mjs';
23
+ import { writeJsonSafe } from '../shared/json-file.mjs';
20
24
  import {
21
25
  expectedWebHost,
22
26
  isLoopbackHost,
@@ -32,6 +36,56 @@ import { API_VERSION } from '../web/api-version.mjs';
32
36
  import { ensureTmuxAvailable } from '../tmux.mjs';
33
37
 
34
38
  export const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
39
+ const WEB_START_LOCK_WAIT_MS = WEB_RUNTIME_START_TIMEOUT_MS + 30_000;
40
+
41
+ function webStartOwnerAlive(owner) {
42
+ const pid = owner?.pid;
43
+ if (!Number.isInteger(pid) || pid <= 0) return true;
44
+ const observed = inspectProcessIdentity(pid);
45
+ if (observed.state === 'dead') return false;
46
+ if (owner.process_identity && observed.state === 'live') {
47
+ return compareProcessIdentity(owner.process_identity, observed.identity) !== 'dead';
48
+ }
49
+ return true;
50
+ }
51
+
52
+ async function withWebStartLock(fn) {
53
+ const lockFile = path.join(globalStateDir(), 'web-start.lock');
54
+ const owner = {
55
+ pid: process.pid,
56
+ process_identity: inspectProcessIdentity(process.pid).identity,
57
+ nonce: randomUUID()
58
+ };
59
+ const deadline = performance.now() + WEB_START_LOCK_WAIT_MS;
60
+ while (true) {
61
+ const acquired = withFileLock(lockFile, (lockedFile) => {
62
+ if (fs.existsSync(lockedFile)) {
63
+ let previous = null;
64
+ try { previous = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
65
+ if (!previous || webStartOwnerAlive(previous)) return false;
66
+ }
67
+ writeJsonSafe(lockedFile, owner, { mode: 0o600 });
68
+ return true;
69
+ });
70
+ if (acquired) break;
71
+ if (performance.now() >= deadline) {
72
+ throw new CliError('WEB_START_LOCK_TIMEOUT',
73
+ 'Timed out waiting for another web startup to finish.');
74
+ }
75
+ await sleep(100);
76
+ }
77
+ try {
78
+ return await fn();
79
+ } finally {
80
+ withFileLock(lockFile, (lockedFile) => {
81
+ let current = null;
82
+ try { current = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
83
+ if (current?.nonce === owner.nonce && current.pid === owner.pid) {
84
+ fs.rmSync(lockedFile, { force: true });
85
+ }
86
+ });
87
+ }
88
+ }
35
89
 
36
90
  export function createWebStartup(deps) {
37
91
  const {
@@ -84,25 +138,41 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
84
138
  const ppid = Number.parseInt(match[2], 10);
85
139
  if (!Number.isFinite(pid)) continue;
86
140
  if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
87
- rows.push({ pid, args: match[3] });
141
+ rows.push({ pid, ppid, args: match[3] });
88
142
  }
89
143
 
90
144
  const ancestorPids = currentProcessAncestorPids(ppidByPid);
91
- const pids = [];
145
+ const candidates = [];
92
146
  for (const row of rows) {
147
+ // A live parent still owns its web command. In particular, another CLI
148
+ // invocation waiting for the startup lock is not an orphan runtime.
149
+ if (row.ppid !== 1) continue;
93
150
  if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
94
- if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
151
+ if (!hccWebProcessMatches(row.args, ctx)) continue;
152
+ const observed = inspectProcessIdentity(row.pid);
153
+ if (observed.state === 'live' &&
154
+ observed.identity.commandHash === createHash('sha256').update(row.args.trim()).digest('hex')) {
155
+ candidates.push(observed.identity);
156
+ }
95
157
  }
96
- if (!pids.length) return;
158
+ if (!candidates.length) return;
159
+
160
+ const stillSameProcess = (identity) => {
161
+ const observed = inspectProcessIdentity(identity.pid);
162
+ return observed.state === 'live' &&
163
+ compareProcessIdentity(identity, observed.identity) === 'live' &&
164
+ identity.commandHash === observed.identity.commandHash;
165
+ };
97
166
 
98
- for (const pid of pids) {
99
- try { process.kill(pid, 'SIGTERM'); } catch {}
167
+ for (const identity of candidates) {
168
+ if (!stillSameProcess(identity)) continue;
169
+ try { process.kill(identity.pid, 'SIGTERM'); } catch {}
100
170
  }
101
171
  await sleep(250);
102
- for (const pid of pids) {
172
+ for (const identity of candidates) {
173
+ if (!stillSameProcess(identity)) continue;
103
174
  try {
104
- process.kill(pid, 0);
105
- process.kill(pid, 'SIGKILL');
175
+ process.kill(identity.pid, 'SIGKILL');
106
176
  } catch {}
107
177
  }
108
178
  }
@@ -160,6 +230,10 @@ async function startWebBackground(ctx, args) {
160
230
  assertWebTokenForHost(requestedHost, !opts['no-token']);
161
231
  if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
162
232
  ensureTmuxAvailable({ autoInstall: true });
233
+ return withWebStartLock(() => startWebBackgroundLocked(ctx, args, opts));
234
+ }
235
+
236
+ async function startWebBackgroundLocked(ctx, args, opts) {
163
237
  const setup = await prepareLocalBus(ctx, {
164
238
  ...opts,
165
239
  installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true