@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
@@ -5,6 +5,8 @@ import { compareProcessIdentity, inspectProcessIdentity } from '../process/ident
5
5
  import { CliError } from '../shared/errors.mjs';
6
6
  import { withFileLock } from '../shared/file-lock.mjs';
7
7
  import { writeJsonSafe } from '../shared/json-file.mjs';
8
+ import { readPrivateTextFile } from './private-file.mjs';
9
+ import { resolveProjectDatabase, secureExistingProjectStateDirectory } from './project-path.mjs';
8
10
  import {
9
11
  globalRuntimePath,
10
12
  runtimePath
@@ -196,9 +198,10 @@ function runtimeMetadataMatchesPointer(runtime, metadata) {
196
198
 
197
199
  export function readGlobalRuntimeFile() {
198
200
  const file = globalRuntimePath();
199
- if (!fs.existsSync(file)) return null;
201
+ const raw = readPrivateTextFile(file);
202
+ if (raw === null) return null;
200
203
  try {
201
- return JSON.parse(fs.readFileSync(file, 'utf8'));
204
+ return JSON.parse(raw);
202
205
  } catch {
203
206
  // Do not delete on a parse failure: a torn/partial write must not orphan a
204
207
  // healthy runtime pointer. Leave it for health probing / restart to reclaim.
@@ -213,6 +216,8 @@ export function writeGlobalRuntime(runtime) {
213
216
  }
214
217
 
215
218
  export function writeRuntime(ctx, runtime) {
219
+ // Path derivation alone does not validate the root-to-private-store binding.
220
+ resolveProjectDatabase({ root: ctx.root, createStateDir: true });
216
221
  const file = runtimePath(ctx);
217
222
  withFileLock(file, (lockedFile) => writeJsonSafe(lockedFile, runtime, { mode: 0o600 }));
218
223
  return file;
@@ -231,10 +236,12 @@ export function readRuntime(ctx, opts = {}) {
231
236
  source: 'env'
232
237
  };
233
238
  }
239
+ secureExistingProjectStateDirectory(ctx.root);
234
240
  const file = runtimePath(ctx);
235
- if (fs.existsSync(file)) {
241
+ const raw = readPrivateTextFile(file);
242
+ if (raw !== null) {
236
243
  try {
237
- const runtime = JSON.parse(fs.readFileSync(file, 'utf8'));
244
+ const runtime = JSON.parse(raw);
238
245
  if (!runtime.base_url) throw new Error('missing base_url');
239
246
  if (!runtimePointerMatchesCurrentProcess(runtime, { inspect: inspectRuntimeProcessIdentity })) {
240
247
  throw new Error('runtime process identity does not match');
@@ -260,9 +267,10 @@ export function readRuntime(ctx, opts = {}) {
260
267
  }
261
268
 
262
269
  export function readRuntimeFile(ctx) {
270
+ secureExistingProjectStateDirectory(ctx.root);
263
271
  const file = runtimePath(ctx);
264
- if (!fs.existsSync(file)) return null;
265
- return JSON.parse(fs.readFileSync(file, 'utf8'));
272
+ const raw = readPrivateTextFile(file);
273
+ return raw === null ? null : JSON.parse(raw);
266
274
  }
267
275
 
268
276
  export async function probeRuntime(runtime) {
@@ -303,12 +311,14 @@ export async function readHealthyGlobalRuntime() {
303
311
  }
304
312
 
305
313
  export function clearRuntime(ctx, pid = process.pid) {
306
- const file = runtimePath(ctx);
307
314
  try {
315
+ secureExistingProjectStateDirectory(ctx.root);
316
+ const file = runtimePath(ctx);
308
317
  withFileLock(file, (lockedFile) => {
309
- if (!fs.existsSync(lockedFile)) return;
318
+ const raw = readPrivateTextFile(lockedFile);
319
+ if (raw === null) return;
310
320
  try {
311
- const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
321
+ const runtime = JSON.parse(raw);
312
322
  if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
313
323
  } catch {
314
324
  // Leave an unparseable pointer in place: only remove when we can confirm
@@ -319,9 +329,10 @@ export function clearRuntime(ctx, pid = process.pid) {
319
329
  const globalFile = globalRuntimePath();
320
330
  try {
321
331
  withFileLock(globalFile, (lockedFile) => {
322
- if (!fs.existsSync(lockedFile)) return;
332
+ const raw = readPrivateTextFile(lockedFile);
333
+ if (raw === null) return;
323
334
  try {
324
- const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
335
+ const runtime = JSON.parse(raw);
325
336
  if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
326
337
  } catch {
327
338
  // Same as above: do not blind-delete a torn global runtime pointer.
@@ -0,0 +1,6 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function automaticResultKey({ executorId, threadId, turnId, kind, itemId = null }) {
4
+ const identity = [executorId, threadId, turnId, kind, itemId];
5
+ return `automatic:${kind}:${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}`;
6
+ }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
@@ -8,11 +9,59 @@ export function readJsonSafe(filePath) {
8
9
 
9
10
  export function writeJsonSafe(filePath, data, opts = {}) {
10
11
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
11
- const tmpPath = filePath + '.tmp.' + process.pid;
12
- const writeOpts = opts.mode !== undefined ? { mode: opts.mode } : undefined;
13
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', writeOpts);
14
- fs.renameSync(tmpPath, filePath);
15
- if (opts.mode !== undefined) {
16
- try { fs.chmodSync(filePath, opts.mode); } catch {}
12
+ const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(16).toString('hex')}`;
13
+ const contents = JSON.stringify(data, null, 2) + '\n';
14
+ let fd = null;
15
+ let opened = null;
16
+ let renamed = false;
17
+ try {
18
+ fd = fs.openSync(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT |
19
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
20
+ opened = fs.fstatSync(fd);
21
+ if (!opened.isFile() || opened.nlink !== 1 ||
22
+ (typeof process.geteuid === 'function' && opened.uid !== process.geteuid())) {
23
+ throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
24
+ }
25
+ if (process.platform !== 'win32') fs.fchmodSync(fd, 0o600);
26
+ const privateStat = fs.fstatSync(fd);
27
+ if (!privateStat.isFile() || privateStat.nlink !== 1 ||
28
+ privateStat.dev !== opened.dev || privateStat.ino !== opened.ino ||
29
+ (typeof process.geteuid === 'function' && privateStat.uid !== process.geteuid()) ||
30
+ (process.platform !== 'win32' && (privateStat.mode & 0o777) !== 0o600)) {
31
+ throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
32
+ }
33
+ fs.writeFileSync(fd, contents, 'utf8');
34
+ if (opts.mode !== undefined && opts.mode !== 0o600 && process.platform !== 'win32') {
35
+ fs.fchmodSync(fd, opts.mode);
36
+ }
37
+ const ready = fs.fstatSync(fd);
38
+ if (!ready.isFile() || ready.nlink !== 1 ||
39
+ ready.dev !== opened.dev || ready.ino !== opened.ino ||
40
+ (typeof process.geteuid === 'function' && ready.uid !== process.geteuid()) ||
41
+ (process.platform !== 'win32' && (ready.mode & 0o777) !== (opts.mode ?? 0o600))) {
42
+ throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
43
+ }
44
+ fs.fsyncSync(fd);
45
+ fs.closeSync(fd);
46
+ fd = null;
47
+ const current = fs.lstatSync(tmpPath);
48
+ if (!current.isFile() || current.nlink !== 1 ||
49
+ current.dev !== opened.dev || current.ino !== opened.ino ||
50
+ (typeof process.geteuid === 'function' && current.uid !== process.geteuid()) ||
51
+ (process.platform !== 'win32' && (current.mode & 0o777) !== (opts.mode ?? 0o600))) {
52
+ throw new Error(`JSON temporary file changed before rename: ${tmpPath}`);
53
+ }
54
+ fs.renameSync(tmpPath, filePath);
55
+ renamed = true;
56
+ } finally {
57
+ if (fd !== null) fs.closeSync(fd);
58
+ if (!renamed && opened) {
59
+ try {
60
+ const current = fs.lstatSync(tmpPath);
61
+ if (current.dev === opened.dev && current.ino === opened.ino) fs.unlinkSync(tmpPath);
62
+ } catch {
63
+ // Preserve the write failure. Any remaining temp file is private.
64
+ }
65
+ }
17
66
  }
18
67
  }
package/lib/ui/help.mjs CHANGED
@@ -21,7 +21,8 @@ Commands:
21
21
  down Stop the running hello-cc web runtime
22
22
  update Update the global npm install of hello-cc
23
23
  uninstall Remove hooks, shims, and optional project data
24
- init Initialize .hello-cc/mesh.db and guidance
24
+ init Initialize private managed mesh.db and guidance
25
+ migrate-state --offline --yes Copy stopped legacy state to private per-user storage
25
26
  register --peer ID Register or update a peer session identity
26
27
  join --peer ID Register this shell and print eval-able env
27
28
  env --peer ID Print eval-able HCC_PEER/HCC_ROOT/HCC_DB exports
@@ -405,9 +406,10 @@ Usage:
405
406
  Stops the current project runtime and removes user-level hello-cc integrations:
406
407
  Claude/Codex hooks, claude/codex shims, and the shell PATH entry for shims.
407
408
 
408
- With --purge --yes, also removes current project data:
409
- .hello-cc/
410
- hello-cc blocks from CLAUDE.md and AGENTS.md
409
+ With --purge --yes, also removes the selected managed project state directory
410
+ (project-local for stable roots, otherwise under ~/.hello-cc/projects/) and
411
+ hello-cc blocks from CLAUDE.md and AGENTS.md. An old directory retained by
412
+ offline migration is not removed.
411
413
  `);
412
414
  },
413
415
 
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { redactMcpUrlInteraction } from '../integrations/mcp-url-elicitation.mjs';
3
4
  import path from 'node:path';
4
5
  import { CODEX_INTERACTION_METHODS, codexInteractionKind, codexInteractionResponse, cancelCodexInteraction, codexInteractiveConfig } from '../integrations/codex-interactions.mjs';
5
6
 
@@ -10,6 +11,7 @@ const APPROVAL_METHODS = CODEX_INTERACTION_METHODS;
10
11
  const TERMINAL_TURNS = new Set(['completed', 'interrupted', 'failed']);
11
12
  const MAX_LINE_BYTES = 8 * 1024 * 1024;
12
13
  const MAX_TEXT = 65536;
14
+ const STREAM_UPDATE_MS = 50;
13
15
 
14
16
  export class CodexAppServerError extends Error {
15
17
  constructor(code, message, extra = {}) {
@@ -70,6 +72,8 @@ export function createCodexAppServer({
70
72
  let buffer = '';
71
73
  let truncated = false;
72
74
  let closePromise = null;
75
+ let streamTimer = null;
76
+ let streamEvent = null;
73
77
  const threads = new Map();
74
78
  const pending = new Map();
75
79
  const approvals = new Map();
@@ -111,11 +115,26 @@ export function createCodexAppServer({
111
115
  });
112
116
  }
113
117
 
118
+ function publishChange(event) {
119
+ clearTimeout(streamTimer); streamTimer = null; streamEvent = null;
120
+ try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
121
+ }
122
+
114
123
  function changed(method, params = {}) {
115
- const event = { method, params: bounded(params, 8192), at: Date.now() };
124
+ const event = { method, params: bounded(redactMcpUrlInteraction(params), 8192), at: Date.now() };
116
125
  events.push(event);
117
126
  if (events.length > 100) events.shift();
118
- try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
127
+ // Keep protocol state current on every token, but copy/broadcast its complete
128
+ // retained history at most once per interval during a text-only burst.
129
+ // Approval, completion, error and connection changes publish immediately;
130
+ // their snapshot includes all pending text and cancels the trailing timer.
131
+ if (method === 'item/agentMessage/delta') {
132
+ streamEvent = event;
133
+ if (!streamTimer) {
134
+ streamTimer = setTimeout(() => publishChange(streamEvent), STREAM_UPDATE_MS);
135
+ streamTimer.unref?.();
136
+ }
137
+ } else publishChange(event);
119
138
  }
120
139
 
121
140
  function ensureThread(id) {
@@ -315,7 +334,8 @@ export function createCodexAppServer({
315
334
  if (approvals.has(key)) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'server reused an unresolved request id');
316
335
  if (approvals.size >= 64) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'too many unresolved approvals');
317
336
  thread.activeTurnId = turnId;
318
- thread.status = { type: 'active', activeFlags: [codexInteractionKind(message.method) === 'userInput' ? 'waitingOnUserInput' : 'waitingOnApproval'] };
337
+ const inputRequired = codexInteractionKind(message.method) === 'userInput' || (message.method === 'mcpServer/elicitation/request' && params.mode === 'form' && Object.keys(params.requestedSchema?.properties || {}).length > 0);
338
+ thread.status = { type: 'active', activeFlags: [inputRequired ? 'waitingOnUserInput' : 'waitingOnApproval'] };
319
339
  const preview = bounded(params);
320
340
  approvals.set(key, {
321
341
  executorId, requestId: message.id, threadId, turnId, itemId: params.itemId || null,
@@ -533,7 +553,7 @@ export function createCodexAppServer({
533
553
  assertActiveTurn(threadId, turnId);
534
554
  return call('turn/interrupt', { threadId, turnId }, true).then(result => { expireApprovals(threadId, turnId); changed('approval.expired', { threadId, turnId }); return result; });
535
555
  },
536
- async approve({ executorId: submittedExecutorId, threadId, turnId, requestId, decision, permissions, scope, answers } = {}) {
556
+ async approve({ executorId: submittedExecutorId, threadId, turnId, requestId, decision, permissions, scope, answers, content } = {}) {
537
557
  if (submittedExecutorId !== executorId) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval identity or decision is invalid');
538
558
  assertActiveTurn(threadId, turnId);
539
559
  const approval = approvals.get(rpcKey(requestId));
@@ -543,13 +563,14 @@ export function createCodexAppServer({
543
563
  if (approval.truncated && decision === 'accept') {
544
564
  throw new CodexAppServerError('CODEX_APPROVAL_TRUNCATED', 'approval details exceed the preview limit; decline or cancel instead');
545
565
  }
546
- const result = codexInteractionResponse({ ...approval, kind: approval.kind || codexInteractionKind(approval.method) }, { decision, permissions, scope, answers });
566
+ const result = codexInteractionResponse({ ...approval, kind: approval.kind || codexInteractionKind(approval.method) }, { decision, permissions, scope, answers, content });
547
567
  await initialize();
548
568
  // No await between checking a live request and replying to its exact RPC id.
549
569
  assertActiveTurn(threadId, turnId);
550
570
  if (approvals.get(rpcKey(requestId)) !== approval) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval was resolved');
551
571
  write({ id: requestId, result });
552
572
  approval.status = 'submitted';
573
+ approval.params = redactMcpUrlInteraction(approval).params;
553
574
  changed('approval.submitted', { executorId, threadId, turnId, requestId, decision });
554
575
  return { executorId, threadId, turnId, requestId, decision, status: 'submitted' };
555
576
  }
@@ -80,8 +80,9 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
80
80
  peer: id, executorId, ownerIdentity,
81
81
  binding: { transport: 'app-server', runtimeSessionId: id } });
82
82
  return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
83
- }, onChange(state) {
84
- broadcast(session, { type: 'codex_state', state });
83
+ }, onChange(state, event) {
84
+ session.executorId ||= state.executorId;
85
+ broadcast(session, { type: 'codex_state', state }, event);
85
86
  if (state.status === 'disconnected' && !closeScheduled) {
86
87
  closeScheduled = true;
87
88
  queueMicrotask(() => { if (adapter) void adapter.close(); });
@@ -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, () => {
@@ -158,7 +160,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
158
160
  if (action === 'interrupt') return adapter.interrupt(threadId, String(input.turnId || ''));
159
161
  if (action === 'approve') return adapter.approve({ executorId: input.executorId,
160
162
  threadId, turnId: input.turnId, requestId: input.requestId, decision: input.decision,
161
- permissions: input.permissions, scope: input.scope, answers: input.answers });
163
+ permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content });
162
164
  if (!['turn', 'steer'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
163
165
  const text = String(input.text || '').trim();
164
166
  if (!text || text.length > 100000) throw new CliError('BAD_REQUEST', 'A message of 1 to 100000 characters is required');
@@ -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
 
@@ -5,6 +5,8 @@
5
5
 
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import process from 'node:process';
9
+ import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
8
10
  import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
9
11
  import {
10
12
  externalBufferEvidence,
@@ -13,19 +15,50 @@ import {
13
15
  readExternalBufferMetadata
14
16
  } from '../runtime/buffer-evidence.mjs';
15
17
  import { inspectProcessIdentity } from '../process/identity.mjs';
18
+ import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
19
+
20
+ function secureExistingBridgeFile(file) {
21
+ let stat;
22
+ try { stat = fs.lstatSync(file); }
23
+ catch (error) {
24
+ if (error?.code === 'ENOENT') return false;
25
+ throw error;
26
+ }
27
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
28
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
29
+ (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) ||
30
+ unsafeDirectoryAcl(file, stat)) {
31
+ throw new Error(`Unsafe external buffer file: ${file}`);
32
+ }
33
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
34
+ try {
35
+ const opened = fs.fstatSync(fd);
36
+ if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
37
+ throw new Error(`External buffer file changed: ${file}`);
38
+ }
39
+ if ((process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600) ||
40
+ unsafeDirectoryAcl(file, opened)) {
41
+ throw new Error(`External buffer file lost private permissions: ${file}`);
42
+ }
43
+ const current = fs.lstatSync(file);
44
+ if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
45
+ throw new Error(`External buffer file changed: ${file}`);
46
+ }
47
+ return true;
48
+ } finally { fs.closeSync(fd); }
49
+ }
16
50
 
17
51
  export function createExternalSessions(deps) {
18
52
  const {
19
53
  ctx, sessions,
20
54
  sessionKey, broadcast,
21
55
  now, tx, connectWebProject,
22
- bufferDirectory, runtimeProjectContexts,
56
+ runtimeProjectContexts,
23
57
  refreshPeerIoHeartbeat, redactedLogText, BUFS_DIR_NAME
24
58
  } = deps;
25
59
 
26
60
  // ── Optional external buffer-file session adoption ───────────────────────
27
- const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
28
- fs.mkdirSync(bufsDir, { recursive: true });
61
+ const bufsDir = ensurePrivateProjectBufferDirectory(ctx.root, BUFS_DIR_NAME);
29
62
 
30
63
  function removeExternalBufferFiles(id, directory = bufsDir) {
31
64
  for (const suffix of ['out', 'in', 'resize', 'meta']) {
@@ -34,6 +67,15 @@ export function createExternalSessions(deps) {
34
67
  }
35
68
 
36
69
  function adoptExternalSession(id, pctx = ctx, directory = bufsDir) {
70
+ let privateDirectory;
71
+ let suppliedDirectory;
72
+ try {
73
+ privateDirectory = ensurePrivateProjectBufferDirectory(pctx.root, BUFS_DIR_NAME, { create: false });
74
+ suppliedDirectory = fs.realpathSync.native(directory);
75
+ }
76
+ catch { return; }
77
+ if (!privateDirectory || suppliedDirectory !== privateDirectory) return;
78
+ directory = privateDirectory;
37
79
  const key = sessionKey(pctx, id);
38
80
  if (sessions.has(key)) return;
39
81
  const outFile = path.join(directory, `${id}.out`);
@@ -44,6 +86,9 @@ export function createExternalSessions(deps) {
44
86
  try {
45
87
  adopted = withBufferDirectoryLease(directory, () => {
46
88
  if (!fs.existsSync(outFile) || !fs.existsSync(metaFile)) return null;
89
+ for (const file of [outFile, inFile, resizeFile, metaFile]) {
90
+ secureExistingBridgeFile(file);
91
+ }
47
92
  let meta;
48
93
  try { meta = readExternalBufferMetadata(metaFile); } catch { return null; }
49
94
  // The producer publishes wrapper evidence before the PTY child identity
@@ -211,7 +256,10 @@ export function createExternalSessions(deps) {
211
256
  // Adopt any already-running external sessions
212
257
  function scanExternalSessions() {
213
258
  for (const projectCtx of runtimeProjectContexts()) {
214
- const directory = bufferDirectory(projectCtx);
259
+ let directory;
260
+ try { directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false }); }
261
+ catch { continue; }
262
+ if (!directory) continue;
215
263
  for (const id of externalBufferSessionIds(directory)) {
216
264
  adoptExternalSession(id, projectCtx, directory);
217
265
  }
@@ -230,8 +278,9 @@ export function createExternalSessions(deps) {
230
278
  for (const projectCtx of runtimeProjectContexts()) {
231
279
  const root = path.resolve(projectCtx.root);
232
280
  if (bufsWatchers.has(root)) continue;
233
- const directory = bufferDirectory(projectCtx);
234
281
  try {
282
+ const directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false });
283
+ if (!directory) continue;
235
284
  const watcher = fs.watch(directory, { persistent: false }, (event, filename) => {
236
285
  if (filename?.endsWith('.out')) {
237
286
  setTimeout(() => adoptExternalSession(path.basename(filename, '.out'), projectCtx, directory), 300);
@@ -2,17 +2,17 @@
2
2
  // The single handleWebRequest dispatcher: auth/cookie exchange, static UI,
3
3
  // projects, sessions, peers, tmux/buffer-GC endpoints, and the status APIs.
4
4
 
5
- import path from 'node:path';
6
5
  import process from 'node:process';
7
6
  import { URL } from 'node:url';
8
7
  import { publicCliFailure, CliError } from '../shared/errors.mjs';
9
8
  import { required } from '../cli-args.mjs';
10
- import { packageRoot } from '../cli-runtime.mjs';
9
+ import { terminalAssetPath } from './terminal-assets.mjs';
11
10
  import { projectRecord } from '../runtime/projects.mjs';
11
+ import { projectDbPath } from '../runtime/paths.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') {
@@ -163,7 +167,7 @@ export function createHttpRoutes(deps) {
163
167
  const requestedRoot = input.root || reqCtx.root;
164
168
  const projectCtx = rememberProject(resolveWebProjectContext(
165
169
  requestedRoot,
166
- input.db || path.join(requestedRoot, '.hello-cc', 'mesh.db')
170
+ input.db || projectDbPath(requestedRoot)
167
171
  ), { register: true, nonblocking: true });
168
172
  const db = connectWebProject(projectCtx);
169
173
  db.close();
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) {
@@ -249,7 +257,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
249
257
  ...identity, ...(action === 'send' ? { from: 'web', body: text, submissionId }
250
258
  : action === 'interrupt' ? { turnId: input.turnId || session.nativeState.turnId || undefined }
251
259
  : action === 'respond' ? { executorId: input.executorId, requestId: input.requestId, turnId: input.turnId,
252
- decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers } : {})
260
+ decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content } : {})
253
261
  });
254
262
  if (action === 'send') {
255
263
  if (!Number.isSafeInteger(result?.message_id) || result.submission_id !== submissionId || typeof result.state !== 'string') {
@@ -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
  }